diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 1bb23e0..de9cea6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ __pycache__/* *.pyc cookies/* +/cookies/* +geocache.db +/.uv-cache/ +/AI Chats/ diff --git a/.idea/pyLspTools.xml b/.idea/pyLspTools.xml new file mode 100644 index 0000000..e202fc5 --- /dev/null +++ b/.idea/pyLspTools.xml @@ -0,0 +1,34 @@ + + + + + + \ No newline at end of file diff --git a/.idea/pymd3_vue_location_sim.iml b/.idea/pymd3_vue_location_sim.iml index d174d5c..e0d5fa5 100644 --- a/.idea/pymd3_vue_location_sim.iml +++ b/.idea/pymd3_vue_location_sim.iml @@ -4,6 +4,9 @@ + + + diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc index 7147ec6..ef08a1d 100644 Binary files a/__pycache__/main.cpython-314.pyc and b/__pycache__/main.cpython-314.pyc differ diff --git a/geocache.db b/geocache.db index 3ddbc83..cddc8d2 100644 Binary files a/geocache.db and b/geocache.db differ diff --git a/pyproject.toml b/pyproject.toml index c5a2b73..0789a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "sqlalchemy-orm>=1.2.10", "typer>=0.24.1", "typing==3.10.0.0", + "uptime>=3.0.1", "uvicorn==0.41.0", ] diff --git a/src/pymd3_vue_location_sim/geo_cache.py b/src/pymd3_vue_location_sim/geo_cache.py index d0f53a2..fa0c504 100644 --- a/src/pymd3_vue_location_sim/geo_cache.py +++ b/src/pymd3_vue_location_sim/geo_cache.py @@ -2,25 +2,27 @@ import logging import sqlite3 import json from geopy.geocoders import Nominatim +from geopy.distance import geodesic from geopy.adapters import AioHTTPAdapter from geopy.extra.rate_limiter import AsyncRateLimiter -logger = logging.getLogger("ios-api") -CACHE_LOOKUP_SQL = "SELECT address, favorite FROM location_cache WHERE lat_lon = ?" -CACHE_UPSERT_SQL = """ -INSERT INTO location_cache (lat_lon, address) -VALUES (?, ?) -ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address -""" -FAVORITE_LOOKUP_SQL = "SELECT favorite FROM location_cache WHERE favorite IS NOT NULL" -FAVORITE_UPSERT_SQL = """ -INSERT INTO location_cache (lat_lon, favorite) -VALUES (?, ?) -ON CONFLICT(lat_lon) DO UPDATE SET favorite = excluded.favorite -""" -FAVORITE_CLEAR_SQL = "UPDATE location_cache SET favorite = NULL WHERE lat_lon = ?" +from .models import Favorite +logger = logging.getLogger("ios-api") +CACHE_LOOKUP_SQL = "SELECT l.address AS address, f.favorite as favorite, f.category_id as category_id FROM location_cache l LEFT JOIN favorites f ON l.favorite_id = f.favorite_id WHERE l.lat_lon = ?" +CACHE_UPSERT_SQL = "INSERT INTO location_cache (lat_lon, address) VALUES (?, ?) ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address" +CATEGORY_LOOKUP_SQL = "SELECT c.category_id, c.name, c.icon, c.color, GROUP_CONCAT(f.favorite_id, ', ') AS favorite_list FROM categories c LEFT JOIN favorites f ON c.category_id = f.category_id GROUP BY c.category_id WHERE category_id = ?" +CATEGORIES_LOOKUP_SQL = "SELECT c.category_id, c.name, c.icon, c.color, GROUP_CONCAT(f.favorite_id, ', ') AS favorite_list FROM categories c LEFT JOIN favorites f ON c.category_id = f.category_id GROUP BY c.category_id" +CATEGORY_CLEAR_SQL = "DELETE FROM categories WHERE category_id = ?" +CATEGORY_UPSERT_SQL = "INSERT INTO categories (name, icon, color) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET icon = excluded.icon, color = excluded.color" +FAVORITES_LOOKUP_SQL = "SELECT f.favorite_id, f.favorite, f.category_id from favorites f" +FAVORITE_LOOKUP_SQL = "SELECT f.favorite, f.category_id from favorites f WHERE f.favorite_id = ?" +FAVORITE_CLEAR_SQL = "DELETE FROM favorites WHERE favorite_id = ?" +FAVORITE_UPSERT_SQL = "INSERT INTO favorites (name, favorite, category_id) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET favorite = excluded.favorite, category_id = excluded.category_id" +FAVORITE_CACHE_UPSERT_SQL = "INSERT INTO location_cache (lat_lon, favorite_id) VALUES (?, ?) ON CONFLICT(lat_lon) DO UPDATE SET favorite_id = excluded.favorite_id" +FAVORITE_CACHE_ALL_INSERT_SQL = "INSERT INTO location_cache (lat_lon, address, favorite_id) VALUES (?, ?, ?) ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address, favorite_id = excluded.favorite_id" + class AsyncReverseGeocoder: def __init__( self, @@ -35,104 +37,268 @@ class AsyncReverseGeocoder: """Initializes the SQLite database.""" with sqlite3.connect(self.db_path) as conn: cursor = conn.cursor() + cursor.execute("PRAGMA foreign_keys = ON") + cursor.execute(''' + CREATE TABLE IF NOT EXISTS categories ( + category_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + icon TEXT, + color TEXT + ) + ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS favorites ( + favorite_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + favorite TEXT, + FOREIGN KEY (category_id) REFERENCES categories (category_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ) + ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS location_cache ( lat_lon TEXT PRIMARY KEY, address TEXT, - favorite TEXT - + FOREIGN KEY (favorite_id) REFERENCES favorites (favorite_id) + ON UPDATE CASCADE + ON DELETE SET NULL ) ''') cursor.execute("PRAGMA table_info(location_cache)") columns = {row[1] for row in cursor.fetchall()} - if "favorite" not in columns: - cursor.execute("ALTER TABLE location_cache ADD COLUMN favorite TEXT") + if "favorite" in columns: + cursor.execute("ALTER TABLE location_cache DROP COLUMN favorite") + if "favorite_id" not in columns: + cursor.execute(''' + ALTER TABLE location_cache + ADD COLUMN favorite_id INTEGER REFERENCES favorites (favorite_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ''') + cursor.execute("PRAGMA table_info(favorites)") + columns = {row[1] for row in cursor.fetchall()} + if "category_id" not in columns: + cursor.execute(''' + ALTER TABLE favorites + ADD COLUMN category_id INTEGER REFERENCES categories (category_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ''') conn.commit() @staticmethod def _cache_key(lat: float, lon: float) -> str: return f"{lat:.5f},{lon:.5f}" + @staticmethod + def trace_callback(sql): + logger.info("SQL: %s", sql) + def _read_cached_address(self, key: str) -> dict | None: with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + conn.set_trace_callback(self.trace_callback) cursor = conn.execute(CACHE_LOOKUP_SQL, (key,)) row = cursor.fetchone() cached = {} if row: - cached['address'] = json.loads(row[0]) if row[0] else {} - cached['favorite'] = json.loads(row[1]) if row[1] else {} + address = json.loads(row[0]) if row[0] else {} + favorite = json.loads(row[1]) if row[1] else {} + favorite['category_id'] = row[2] if row[2] else None + cached['address'] = address + cached['favorite'] = favorite return cached if row else None + def _read_favorite(self, favorite_id: int) -> dict | None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.execute(FAVORITE_LOOKUP_SQL, (favorite_id,)) + row = cursor.fetchone() + if row: + for row_id, json_str, cat_id in row: + favorite = json.loads(json_str) + favorite['id'] = row_id + favorite['category_id'] = cat_id + return favorite if favorite else None + + def _read_favorites(self) -> list | None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.execute(FAVORITES_LOOKUP_SQL) + rows = cursor.fetchall() + favorites = [] + if rows: + for row_id, json_str, cat_id in rows: + data = json.loads(json_str) + data['id'] = row_id + data['category_id'] = cat_id + favorites.append(data) + return favorites if favorites else None + + def _read_category(self, category_id: int) -> dict | None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.execute(CATEGORY_LOOKUP_SQL, (category_id,)) + row = cursor.fetchone() + return row[1] if row else None + + def _read_categories(self) -> list | None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.execute(CATEGORIES_LOOKUP_SQL) + rows = cursor.fetchall() + categories = [] + if rows: + for row_id, name, icon, color, favs in rows: + fav_list = favs.split(',') if favs else [] + data = {'id': row_id, 'name': name, 'icon': icon, 'color': color, 'favorite_list': fav_list} + categories.append(data) + return categories if categories else None + def _store_cached_address(self, key: str, address_data: dict) -> None: with sqlite3.connect(self.db_path) as conn: conn.execute(CACHE_UPSERT_SQL, (key, json.dumps(address_data))) conn.commit() - def _read_favorites(self) -> list | None: + def _set_favorite(self, key: str, favorite_id: int) -> None: with sqlite3.connect(self.db_path) as conn: - cursor = conn.execute(FAVORITE_LOOKUP_SQL) - rows = cursor.fetchall() - favorites = [json.loads(row[0]) for row in rows if row[0]] - return favorites if favorites else None + conn.execute(FAVORITE_CACHE_UPSERT_SQL, (key, favorite_id)) + conn.commit() - async def clear_favorite(self, lat: float, lon: float) -> bool: - key = self._cache_key(lat, lon) - cached = self._read_cached_address(key) - if cached and cached.get("favorite"): - favorite = cached.get("favorite") - name = favorite.get("name") if isinstance(favorite, dict) else key - logger.info("Clearing favorite %s", name) - with sqlite3.connect(self.db_path) as conn: - cursor = conn.execute(FAVORITE_CLEAR_SQL, (key,)) - conn.commit() - return cursor.rowcount > 0 - else: - return False + def _store_favorite(self, key: str, name: str, category_id: int, favorite_data: Favorite) -> None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.execute(FAVORITE_UPSERT_SQL, (name, json.dumps(favorite_data), category_id)) + favorite_id = cursor.lastrowid + conn.commit() + if favorite_id is not None: + self._set_favorite(key, favorite_id) - async def set_favorite(self, lat: float, lon: float, favorite_data: dict) -> bool: - key = self._cache_key(lat, lon) - name = favorite_data.get("name") - if not name: - return False - logger.info("Setting favorite location %s to location_cache for %s", name, key) - try: - with sqlite3.connect(self.db_path) as conn: - conn.execute(FAVORITE_UPSERT_SQL, (key, json.dumps(favorite_data))) - conn.commit() - return True - except sqlite3.Error as e: - logger.exception("Failed to set favorite location %s to location_cache for %s: %s", name, key, e) - return False + def _store_category(self, name: str, icon: str, color: str) -> None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute(CATEGORY_UPSERT_SQL, (name, icon, color)) + conn.commit() - async def get_favorites(self) -> list | None: - """Reverse geocode with SQLite cache.""" - logger.info("Checking location_cache for favorites") - favorites = self._read_favorites() - return favorites + def _store_cached_address_with_favorite(self, key: str, address_data: dict, favorite_id: int) -> None: + with sqlite3.connect(self.db_path) as conn: + conn.execute(FAVORITE_CACHE_ALL_INSERT_SQL, (key, json.dumps(address_data), favorite_id)) + conn.commit() - async def get_address(self, lat: float, lon: float) -> dict | None: - """Reverse geocode with SQLite cache.""" - key = self._cache_key(lat, lon) - cached_address = self._read_cached_address(key) - if cached_address is not None: - logger.info("Found cached address for %s", key) - return cached_address + def _clear_favorite(self, favorite_id: int) -> None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute(FAVORITE_CLEAR_SQL, (favorite_id,)) + conn.commit() + def _delete_category(self, category_id: int) -> None: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute(CATEGORY_CLEAR_SQL, (category_id,)) + + async def _lookup_address(self, key: str) -> dict | None: async with Nominatim( - user_agent=self.user_agent, - adapter_factory=AioHTTPAdapter + user_agent=self.user_agent, + adapter_factory=AioHTTPAdapter ) as geolocator: try: reverse = AsyncRateLimiter( geolocator.reverse, min_delay_seconds=1) location = await reverse(key) if location: - response = {} logger.info("Nominatim response: %s", location) - response['address'] = location.raw.get("address", {}) - self._store_cached_address(key, response['address']) - return response + return location.raw.get("address", {}) return None except Exception: logger.exception("Reverse geocoding failed for key=%s", key) return None + + def get_favorites(self) -> list | None: + logger.info("Checking location_cache for favorites") + return self._read_favorites() + + def get_categories(self) -> list | None: + logger.info("Checking location_cache for categories") + return self._read_categories() + + def clear_favorite(self, favorite_id: int) -> bool: + favorite = self._read_favorite(favorite_id) + if favorite: + favorite_name = favorite.get("name") if isinstance(favorite, dict) else favorite_id + logger.info("Clearing favorite %s", favorite_name) + self._clear_favorite(favorite_id) + return True + else: + return False + + def delete_category(self, category_id: int) -> bool: + category = self._read_category(category_id) + if category: + category_name = category.get("name") if isinstance(category, dict) else category_id + logger.info("Clearing category %s", category_name) + self._delete_category(category_id) + return True + else: + return False + + def add_favorite(self, lat: float, lon: float, category_id: int, favorite_data: Favorite) -> bool: + key = self._cache_key(lat, lon) + name = favorite_data.get("name") + if not name: + return False + logger.info("Adding favorite location %s to favorite database for %s", name, key) + self._store_favorite(key, name, category_id, favorite_data) + return True + + def add_category(self, name: str, icon: str, color: str) -> bool: + logger.info("Adding category %s to category database", name) + self._store_category(name, icon, color) + return True + + async def get_address(self, lat: float, lon: float) -> dict | None: + key = self._cache_key(lat, lon) + logger.info("Checking location_cache for %s", key) + cached_address = self._read_cached_address(key) + response = {} + if cached_address: + response['address'] = cached_address.get("address") + else: + address = await self._lookup_address(key) + if address is not None: + response['address'] = address + self._store_cached_address(key, response['address']) + else: + logger.error("No address found for %s", key) + return None + + if cached_address and cached_address.get("favorite"): + favorite = cached_address.get("favorite") + name = favorite.get("name") if isinstance(favorite, dict) else key + logger.info("Found favorite: %s", name) + response['favorite'] = favorite + else: + with sqlite3.connect(self.db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.execute(FAVORITES_LOOKUP_SQL) + results = cursor.fetchall() + + for row in results: + favorite_id = row[0] + favorite_data = json.loads(row[1]) + favorite_data['id'] = favorite_id + + favorite_coords = (favorite_data.get("latitude"), favorite_data.get("longitude")) + radius = favorite_data.get("radius") + + distance = geodesic((lat, lon), favorite_coords).meters + logger.info("Distance from %s to favorite %s: %s meters", key, favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown", distance) + if distance <= radius: + response['favorite'] = favorite_data + logger.info("Coordinatees are within favorite radius: %s, setting favorite", favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown") + self._set_favorite(key, favorite_id) + break + else: + logger.info("Coordinates are not within favorite radius, favorite: %s", favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown") + response['favorite'] = None + return response diff --git a/src/pymd3_vue_location_sim/http_routes.py b/src/pymd3_vue_location_sim/http_routes.py new file mode 100644 index 0000000..ef3e87b --- /dev/null +++ b/src/pymd3_vue_location_sim/http_routes.py @@ -0,0 +1,256 @@ +import json +import os +import signal + +import fastapi +import httpx +from fastapi import APIRouter, Request, Response +from fastapi.encoders import jsonable_encoder + +from .json_formatter import logger +from .models import LatLng, SimulationRequestData + + +def generate_http_response( + data: dict, status_code: int = 200, media_type: str = "application/json" +) -> fastapi.Response: + return fastapi.Response( + status_code=status_code, + media_type=media_type, + content=json.dumps(jsonable_encoder(data)), + ) + + +def register_http_routes( + router: APIRouter, + context, + tunnel_service, + icloud_service, + simulation_service, +) -> None: + @router.get("/device/name") + async def rsd_info(): + device_name = await tunnel_service.get_device_name() + return generate_http_response(device_name) + + @router.get("/device/info") + async def device_info(): + return await tunnel_service.device_info() + + @router.get("/") + async def list_tunnels() -> dict[str, list[dict]]: + return tunnel_service.list_tunnels() + + @router.get("/shutdown") + async def shutdown() -> fastapi.Response: + os.kill(os.getpid(), signal.SIGINT) + data = { + "operation": "shutdown", + "data": True, + "message": "Server shutting down...", + } + return generate_http_response(data) + + @router.get("/clear-tunnels") + async def clear_tunnels() -> fastapi.Response: + return generate_http_response(tunnel_service.clear_tunnels_response()) + + @router.get("/cancel") + async def cancel_tunnel(udid: str) -> fastapi.Response: + return generate_http_response(tunnel_service.cancel_tunnel_response(udid)) + + @router.get("/hello") + async def hello() -> fastapi.Response: + return generate_http_response({"message": "Hello, I'm alive"}) + + @router.get("/start-tunnel") + async def start_tunnel( + udid: str | None = None, + ip: str | None = None, + connection_type: str | None = None, + ) -> fastapi.Response: + data = await tunnel_service.start_tunnel_response( + udid=udid, + ip=ip, + connection_type=connection_type, + ) + if isinstance(data, fastapi.Response): + return data + return generate_http_response(data) + + @router.get("/restart") + async def restart() -> fastapi.Response: + return generate_http_response(await tunnel_service.restart_response()) + + @router.get("/icloud-monitor/start") + async def app_start_icloud_monitor() -> fastapi.Response: + await icloud_service.start() + data = { + "command_status": "OK", + "command_class": "icloud_monitor", + "command": "start", + "icloud_monitor_enabled": context.icloud_monitor_enabled, + "icloud_monitor_running": icloud_service.is_running(), + } + return generate_http_response(data) + + @router.get("/icloud-monitor/stop") + async def app_stop_icloud_monitor() -> fastapi.Response: + await icloud_service.stop() + data = { + "command_status": "OK", + "command_class": "icloud_monitor", + "command": "stop", + "icloud_monitor_enabled": context.icloud_monitor_enabled, + "icloud_monitor_running": icloud_service.is_running(), + } + return generate_http_response(data) + + @router.get("/icloud-monitor/status") + async def app_icloud_monitor_status() -> fastapi.Response: + data = { + "command_status": "OK", + "command_class": "icloud_monitor", + "command": "status", + "icloud_monitor_enabled": context.icloud_monitor_enabled, + "icloud_monitor_running": icloud_service.is_running(), + } + return generate_http_response(data) + + @router.get("/icloud-monitor/refresh") + async def app_refresh_icloud_monitor() -> fastapi.Response: + data = await icloud_service.refresh_location() + return generate_http_response(data) + + @router.get("/simulation/start") + async def app_start_simulation() -> fastapi.Response: + data = await simulation_service.start_queue() + return generate_http_response(data) + + @router.post("/simulation/add") + async def app_add_location(data: SimulationRequestData) -> fastapi.Response: + logger.info("Request to add new location to queue") + resp = await simulation_service.add_location_to_queue(data) + return generate_http_response(resp) + + @router.get("/simulation/clear") + async def app_clear_queue() -> fastapi.Response: + logger.info("Simulation Start Requested ") + simulation_service.clear_future_items() + data = { + "command_status": "OK", + "command_class": "simulation_control", + "command": "clear", + "message": "Simulation cleared", + } + return generate_http_response(data) + + @router.get("/simulation/pause") + async def app_pause_queue() -> fastapi.Response: + await simulation_service.pause_queue() + data = { + "command_status": "OK", + "command_class": "simulation_control", + "command": "pause", + "message": "Simulation paused", + } + return generate_http_response(data) + + @router.get("/simulation/resume") + async def app_resume_queue() -> fastapi.Response: + await simulation_service.resume_queue() + data = { + "command_status": "OK", + "command_class": "simulation_control", + "command": "resume", + "message": "Simulation resumed", + } + return generate_http_response(data) + + @router.get("/simulation/end") + async def app_end_simulation() -> fastapi.Response: + data = await simulation_service.end_queue() + return generate_http_response(data) + + @router.get("/simulation/test") + async def app_simulation_test_mode() -> fastapi.Response: + before_toggle = context.test_mode + response = { + "data": simulation_service.toggle_test_mode(), + "command_status": "OK", + "command_class": "simulation_control", + "command": "test_mode", + "message": f"Test mode toggled from {before_toggle} to {context.test_mode}", + } + return generate_http_response(response) + + @router.get("/simulation/noise") + async def app_simulation_noise() -> fastapi.Response: + before_toggle = context.simulation_noise + response = { + "data": simulation_service.toggle_gps_noise(), + "command_status": "OK", + "command_class": "simulation_control", + "command": "noise", + "message": f"GPS noise toggled from {before_toggle} to {context.simulation_noise}", + } + return generate_http_response(response) + + @router.get("/status/rsd") + async def app_rsd_info(): + rsd_info = {} + if context.tunnel is None: + await tunnel_service.get_tun() + if context.tunnel is not None: + rsd_info = context.tunnel.peer_info + return generate_http_response(rsd_info) + + @router.get("/status/context") + async def app_context_status() -> fastapi.Response: + return generate_http_response(simulation_service.get_status()) + + @router.post("/rev_geocode") + async def app_proxy_osm(data: LatLng): + logger.info("OSM Proxy Request, data: %s", data) + rev_geocode = await simulation_service.get_reverse_geocode(data) + return generate_http_response(rev_geocode) + + @router.get("/ors/status") + async def app_ors_status(request: Request) -> fastapi.Response: + logger.info("request: %s", request) + target_url = "https://ors.intrepidnet.org/ors/v2/status" + async with httpx.AsyncClient() as client: + response = await client.get(target_url, params=request.query_params) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + @router.get("/ors/health") + async def app_ors_health(request: Request) -> fastapi.Response: + logger.info("request: %s", request) + target_url = "https://ors.intrepidnet.org/ors/v2/health" + async with httpx.AsyncClient() as client: + response = await client.get(target_url, params=request.query_params) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + @router.post("/ors/proxy/{full_path:path}") + async def app_proxy_ors(full_path: str, request: Request): + logger.info("request: %s", request) + body = await request.json() + headers = dict(request.headers) + method = request.method + url = "https://ors.intrepidnet.org/" + full_path + logger.info("body: %s", body) + logger.info("headers: %s", headers) + logger.info("method: %s", method) + logger.info("url: %s", url) + + async with httpx.AsyncClient() as client: + response = await client.request(method, url, json=body) + return response.json() diff --git a/src/pymd3_vue_location_sim/icloud_monitor.py b/src/pymd3_vue_location_sim/icloud_monitor.py index f3b1cc8..bce9e0b 100644 --- a/src/pymd3_vue_location_sim/icloud_monitor.py +++ b/src/pymd3_vue_location_sim/icloud_monitor.py @@ -37,6 +37,7 @@ AUTH_EXCEPTIONS = ( PyiCloudNoStoredPasswordAvailableException, ) + class FindMyMonitor: def __init__( self, @@ -59,9 +60,7 @@ class FindMyMonitor: self.sio = sio self.get_client_sids = get_client_sids self.code_timeout_seconds = code_timeout_seconds - self.auth_init_timeout_seconds = int( - os.getenv(ENV_AUTH_INIT_TIMEOUT, "0") - ) + self.auth_init_timeout_seconds = int(os.getenv(ENV_AUTH_INIT_TIMEOUT, "0")) self._logged_candidates = False self._no_location_streak = 0 self._auth_error_streak = 0 @@ -122,7 +121,9 @@ class FindMyMonitor: async def authenticate(self) -> bool: """Authenticates with iCloud, handling 2FA and token storage.""" if not self.username: - logger.warning("APPLE_ID is not configured; skipping iCloud monitor authentication") + logger.warning( + "APPLE_ID is not configured; skipping iCloud monitor authentication" + ) return False has_token = os.path.exists(self.token_file) if not has_token and not self.password: @@ -140,7 +141,9 @@ class FindMyMonitor: if self.api.requires_2fa: logger.info("Two-factor authentication required.") - code = await self._request_code_from_vue("Enter the 6-digit Apple verification code") + code = await self._request_code_from_vue( + "Enter the 6-digit Apple verification code" + ) if code is None: if sys.stdin and sys.stdin.isatty(): code = str( @@ -165,10 +168,12 @@ class FindMyMonitor: await asyncio.to_thread(self.api.trust_session) if self.api.requires_2sa: - logger.info(textwrap.dedent(""" + logger.info( + textwrap.dedent(""" Two-step authentication required. Please select a device to receive a SMS verification code: - """)) + """) + ) for i, device in enumerate(self.api.trusted_devices): logger.info( " %s: %s (%s)", @@ -182,14 +187,23 @@ class FindMyMonitor: "2SA required but no interactive terminal is available; deferring authentication" ) return False - device_index = await asyncio.to_thread(click.prompt, "Please select a device number", type=int) - 1 + device_index = ( + await asyncio.to_thread( + click.prompt, "Please select a device number", type=int + ) + - 1 + ) device = self.api.trusted_devices[device_index] if not await asyncio.to_thread(self.api.send_verification_code, device): logger.warning("Failed to send verification code") return False - code = await asyncio.to_thread(click.prompt, "Please enter verification code", type=int) - if not await asyncio.to_thread(self.api.validate_verification_code, device, code): + code = await asyncio.to_thread( + click.prompt, "Please enter verification code", type=int + ) + if not await asyncio.to_thread( + self.api.validate_verification_code, device, code + ): logger.warning("Failed to verify verification code") return False @@ -223,15 +237,25 @@ class FindMyMonitor: # Select by ID first (exact), then by name, then first device if no selectors set. self.selected_device = None for device in devices_manager: - if self.selected_device_id and device.data.get("id") == self.selected_device_id: + if ( + self.selected_device_id + and device.data.get("id") == self.selected_device_id + ): self.selected_device = device break if self.selected_device is None: for device in devices_manager: - if self.selected_device_name and device.name == self.selected_device_name: + if ( + self.selected_device_name + and device.name == self.selected_device_name + ): self.selected_device = device break - if self.selected_device is None and not self.selected_device_id and not self.selected_device_name: + if ( + self.selected_device is None + and not self.selected_device_id + and not self.selected_device_name + ): for device in devices_manager: self.selected_device = device break @@ -247,20 +271,22 @@ class FindMyMonitor: ) if location: data = { - "latitude": location['latitude'], - "longitude": location['longitude'], - "timeStamp": location['timeStamp'], - "altitude": location['altitude'], - "horizontalAccuracy": location['horizontalAccuracy'], - "verticalAccuracy": location['verticalAccuracy'], - "batteryLevel": status['batteryLevel'], - "deviceDisplayName": status['deviceDisplayName'], - "deviceStatus": status['deviceStatus'], - "name": status['name'] + "latitude": location["latitude"], + "longitude": location["longitude"], + "timeStamp": location["timeStamp"], + "altitude": location["altitude"], + "horizontalAccuracy": location["horizontalAccuracy"], + "verticalAccuracy": location["verticalAccuracy"], + "batteryLevel": status["batteryLevel"], + "deviceDisplayName": status["deviceDisplayName"], + "deviceStatus": status["deviceStatus"], + "name": status["name"], } response = ICloudReturnData(**data) return response - logger.info("Location payload is None for device=%s", self.selected_device.name) + logger.info( + "Location payload is None for device=%s", self.selected_device.name + ) return None logger.warning( @@ -287,8 +313,15 @@ class FindMyMonitor: async def refresh_location(self): """Fetch one location update while serializing iCloud API access.""" + logger.info( + "Refreshig fmf_location for device id='%s' name='%s'", + self.selected_device_id, + self.selected_device_name, + ) async with self._fetch_lock: - return await self.get_location() + fmf_info = await self.get_location() + logger.info("fmf_location: '%s'", fmf_info) + return fmf_info async def run_monitor(self, interval=60): """Runs the monitor loop.""" @@ -338,5 +371,5 @@ class FindMyMonitor: except Exception as e: logger.exception("iCloud monitor loop error: %s", e) sleep_seconds = max(interval, 30) - + await asyncio.sleep(sleep_seconds) diff --git a/src/pymd3_vue_location_sim/icloud_service.py b/src/pymd3_vue_location_sim/icloud_service.py new file mode 100644 index 0000000..8981c69 --- /dev/null +++ b/src/pymd3_vue_location_sim/icloud_service.py @@ -0,0 +1,114 @@ +import asyncio + +from .json_formatter import logger + + +class ICloudMonitorService: + def __init__(self, context): + self.context = context + + async def start(self): + """Start Apple iCloud Find My Monitor to retrieve actual reported device location.""" + logger.info("iCloud monitor start requested") + self.context.icloud_monitor_enabled = True + if ( + self.context.icloud_monitor_task is None + or self.context.icloud_monitor_task.done() + ): + self.context.icloud_monitor_task = asyncio.create_task( + self.context.icloud_monitor.run_monitor(interval=30), + name="icloud-monitor-producer", + ) + logger.info("iCloud monitor producer task started") + if ( + self.context.icloud_consumer_task is None + or self.context.icloud_consumer_task.done() + ): + self.context.icloud_consumer_task = asyncio.create_task( + self.consume_updates(), + name="icloud-monitor-consumer", + ) + logger.info("iCloud monitor consumer task started") + + async def consume_updates(self): + while True: + updated_location = await self.context.fmf_queue.get() + try: + if self.context.fmf_location != updated_location: + self.context.fmf_location = updated_location + await self.context.sio.emit( + "fmf_update", updated_location.model_dump(), namespace="/" + ) + finally: + self.context.fmf_queue.task_done() + + async def refresh_location(self) -> dict: + """Fetch one iCloud location update, with or without monitor loop running.""" + logger.info("iCloud monitor refresh requested") + try: + updated_location = await self.context.icloud_monitor.refresh_location() + except Exception as e: + logger.exception("Failed to refresh iCloud location: %s", e) + return { + "command_status": "ERROR", + "command_class": "icloud_monitor", + "command": "refresh", + "message": "Failed to refresh iCloud location", + "error": type(e).__name__, + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_running(), + } + + if updated_location is None: + return { + "command_status": "OK", + "command_class": "icloud_monitor", + "command": "refresh", + "location_found": False, + "location_updated": False, + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_running(), + } + + location_updated = self.context.fmf_location != updated_location + self.context.fmf_location = updated_location + await self.context.sio.emit( + "fmf_update", updated_location.model_dump(), namespace="/" + ) + return { + "command_status": "OK", + "command_class": "icloud_monitor", + "command": "refresh", + "location_found": True, + "location_updated": location_updated, + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_running(), + "fmf_location": updated_location, + } + + async def stop(self): + logger.info("iCloud monitor stop requested") + self.context.icloud_monitor_enabled = False + self.context.icloud_monitor.stop() + if self.context.icloud_monitor_task is not None: + self.context.icloud_monitor_task.cancel() + try: + await self.context.icloud_monitor_task + except asyncio.CancelledError: + pass + self.context.icloud_monitor_task = None + if self.context.icloud_consumer_task is not None: + self.context.icloud_consumer_task.cancel() + try: + await self.context.icloud_consumer_task + except asyncio.CancelledError: + pass + self.context.icloud_consumer_task = None + + def is_running(self) -> bool: + return ( + self.context.icloud_monitor_task is not None + and not self.context.icloud_monitor_task.done() + and self.context.icloud_consumer_task is not None + and not self.context.icloud_consumer_task.done() + ) diff --git a/src/pymd3_vue_location_sim/models.py b/src/pymd3_vue_location_sim/models.py index 63488b7..950e2c3 100644 --- a/src/pymd3_vue_location_sim/models.py +++ b/src/pymd3_vue_location_sim/models.py @@ -1,9 +1,24 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, TypedDict, NotRequired from pydantic import BaseModel +class Favorite(TypedDict): + id: NotRequired[int] + name: str + latitude: float + longitude: float + radius: float + icon: str + address: dict[str, str | None] + +class Category(TypedDict): + category_id: NotRequired[int] + name: str + icon: str + color: str + class Coordinate(BaseModel): latitude: float diff --git a/src/pymd3_vue_location_sim/server.py b/src/pymd3_vue_location_sim/server.py index 7c047df..3ebedff 100644 --- a/src/pymd3_vue_location_sim/server.py +++ b/src/pymd3_vue_location_sim/server.py @@ -1,72 +1,28 @@ -import asyncio -from datetime import datetime, timezone, timedelta -import json -import uuid import logging import os -import signal -import traceback import warnings -import random -import math +from pathlib import Path +from typing import Optional + import socketio -import httpx -from contextlib import suppress -from typing import Optional, Dict +import uvicorn from dotenv import load_dotenv +from fastapi import APIRouter, FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pymobiledevice3.remote.common import TunnelProtocol +from pymobiledevice3.tunneld.server import TunneldCore + +from .http_routes import register_http_routes +from .icloud_service import ICloudMonitorService +from .json_formatter import JsonFormatter, handler, logger, root_logger +from .simulation_service import SimulationService +from .sio_events import SocketIOEventHandlers +from .state import LocationSimulationState, env_flag +from .tunnel_service import TunnelService with warnings.catch_warnings(): - # Ignore: "Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater." warnings.simplefilter("ignore", category=UserWarning) - import fastapi -import uvicorn -from fastapi import FastAPI, APIRouter, Request, Response -from fastapi.encoders import jsonable_encoder -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse - - -from pymobiledevice3.services.dvt.instruments.location_simulation_base import ( - LocationSimulationBase, -) -from pymobiledevice3.exceptions import ( - ConnectionFailedError, - InvalidServiceError, - MuxException, - TunneldConnectionError, -) -from pymobiledevice3.lockdown import create_using_usbmux, get_mobdev2_lockdowns -from pymobiledevice3.osu.os_utils import get_os_utils -from pymobiledevice3.remote.common import TunnelProtocol -from pymobiledevice3.remote.remote_service_discovery import ( - RemoteServiceDiscoveryService, -) -from pymobiledevice3.remote.tunnel_service import ( - CoreDeviceTunnelProxy, - TunnelResult, - create_core_device_tunnel_service_using_rsd, - get_remote_pairing_tunnel_services, -) -from pymobiledevice3.remote.utils import get_rsds -from pymobiledevice3.services.dvt.instruments.location_simulation import ( - LocationSimulation, -) -from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider -try: - from pymobiledevice3.services.simulate_location import DtSimulateLocation -except Exception: - DtSimulateLocation = None -from pymobiledevice3.tunneld.server import TunneldCore, TunnelTask - -from .icloud_monitor import FindMyMonitor -from .models import ( - SimulationRequestData, - SimulationRequestResponseData, - iCloudLocationData, - LatLng, -) -from .json_formatter import JsonFormatter, handler, root_logger, logger -from .geo_cache import AsyncReverseGeocoder load_dotenv() @@ -75,85 +31,8 @@ root_logger.handlers = [handler] root_logger.setLevel(logging.INFO) -# bugfix: after the device reboots, it might take some time for remoted to start answering the bonjour queries -REATTEMPT_INTERVAL = 5 -REATTEMPT_COUNT = 5 - -REMOTEPAIRING_INTERVAL = 5 -MOBDEV2_INTERVAL = 5 - -# USB monitor will periodically forget what interfaces it has seen -# and force a full rescan. The value is the number of iterations of the -# inner loop (which sleeps one second each) before blowing away the -# `previous_ips` cache. -USB_MONITOR_RESCAN_INTERVAL = 30 - -USBMUX_INTERVAL = 2 -OSUTILS = get_os_utils() -TUNNEL_ACQUIRE_TIMEOUT_SECONDS = 15 -DVT_CONNECT_TIMEOUT_SECONDS = 20 - - -def env_flag(name: str, default: str = "True") -> bool: - return os.getenv(name, default).lower() == "true" - - -class LocationSimulationState: - def __init__(self): - self.connected_clients: set[str] = set() - self.current_location: Optional[Dict[str, str]] = None - self.device_name: Optional[str] = os.getenv("SELECTED_DEVICE_NAME") - self.fmf_location: Optional[iCloudLocationData] = None - self.fmf_queue: asyncio.Queue = asyncio.Queue() - self.icloud_consumer_task = None - self.icloud_monitor = FindMyMonitor(self.fmf_queue) - self.icloud_monitor_task = None - self.icloud_monitor_enabled: bool = False - self.tunnel_watcher_task = None - self.next_move: Optional[float] = None - self.simulation_queue: asyncio.Queue = asyncio.Queue() - self.simulation_queue_data: Dict = {} - self.simulation_queue_order: list[str] = [] - self.simulation_queue_deleted_items: list[str] = [] - self.simulation_queue_pending_ids: set[str] = set() - self.simulation_queue_state: str = "STOPPED" - self.simulation_noise: bool = False - self.simulation_active: bool = False - self.simulation_task: Optional[asyncio.Task] = None - self.sio: socketio.AsyncServer = socketio.AsyncServer( - async_mode="asgi", cors_allowed_origins="*" - ) - self.test_mode: bool = env_flag("TEST_MODE", "True") - self.tunnel: Optional[RemoteServiceDiscoveryService] = None - self.udid: Optional[str] = None - self.reverse_geocode = AsyncReverseGeocoder() - - def get_current_loc_id(self) -> Optional[str]: - if not isinstance(self.current_location, dict): - return None - loc_id = self.current_location.get("loc_id") - if isinstance(loc_id, str) and loc_id: - return loc_id - return None - - def set_current_loc_id(self, loc_id: Optional[str]) -> None: - if isinstance(loc_id, str) and loc_id: - self.current_location = {"loc_id": loc_id} - else: - self.current_location = None - - def get_current_item(self) -> Optional[dict]: - current_loc_id = self.get_current_loc_id() - if current_loc_id is None: - return None - item = self.simulation_queue_data.get(current_loc_id) - if isinstance(item, dict): - return item - return None - - class TunneldRunnerSio: - """TunneldRunner orchestrate between the webserver and TunneldCore""" + """Compose tunneld, FastAPI, Socket.IO, and application services.""" @classmethod def create( @@ -189,44 +68,15 @@ class TunneldRunnerSio: usbmux_monitor: bool = True, mobdev2_monitor: bool = True, ): - async def app_startup() -> None: - logger.info( - "Application startup: starting tunneld core and watcher") - self._tunneld_core.start() - await start_tunnel_watcher() - - async def app_shutdown() -> None: - logger.info("Closing tunneld tasks...") - await end_tunnel_watcher() - await end_icloud_monitor() - await empty_simulation_queue() - await self._tunneld_core.close() - await self.context.sio.shutdown() - self.host = host self.port = port self.protocol = protocol self.context = context or LocationSimulationState() - self._tunneld_api_address = ( - "127.0.0.1" if host in ("0.0.0.0", "::") else host, - port, - ) self._app = APIRouter() - self._vue_dist = os.getenv("VUE_DIST") self._vue_app = FastAPI( title="iOS Device Management API", cors_allowed_origins="*", ) - self._asgi_app = socketio.ASGIApp( - self.context.sio, - self._vue_app, - on_startup=app_startup, - on_shutdown=app_shutdown, - ) - self.context.icloud_monitor.sio = self.context.sio - self.context.icloud_monitor.get_client_sids = lambda: list( - self.context.connected_clients - ) self._tunneld_core = TunneldCore( protocol=protocol, wifi_monitor=wifi_monitor, @@ -235,2270 +85,119 @@ class TunneldRunnerSio: mobdev2_monitor=mobdev2_monitor, ) - """Tunnel Functions""" - - async def get_tun( - udid: Optional[str] = None, max_retries: int = 10, retry_delay: float = 0.5 - ) -> RemoteServiceDiscoveryService: - """Resolve an active local tunnel and connect directly to its RSD endpoint.""" - if self.context.tunnel is not None: - return self.context.tunnel - - for attempt in range(max_retries): - candidates = [] - for active_tunnel in self._tunneld_core.tunnel_tasks.values(): - if active_tunnel.tunnel is None or active_tunnel.udid is None: - continue - if udid is None or active_tunnel.udid == udid: - candidates.append(active_tunnel.tunnel) - - if not candidates: - if attempt < max_retries - 1: - logger.info( - "Waiting for local tunnel to be ready... (attempt %s/%s)", - attempt + 1, - max_retries, - ) - await asyncio.sleep(retry_delay) - continue - logger.error( - "Failed to find an active local tunnel after max retries" - ) - raise TunneldConnectionError() - - tunnel = candidates[0] - if udid and len(candidates) > 1: - logger.warning( - "Multiple local tunnels found for udid %s; using first available", - udid, - ) - - try: - rsd = RemoteServiceDiscoveryService( - (tunnel.address, tunnel.port)) - await rsd.connect() - logger.info( - "Connected to local tunnel at %s:%s after %s retries", - tunnel.address, - tunnel.port, - attempt, - ) - self.context.tunnel = rsd - return rsd - except Exception: - if attempt < max_retries - 1: - logger.info( - "Tunnel endpoint not ready yet... (attempt %s/%s)", - attempt + 1, - max_retries, - ) - await asyncio.sleep(retry_delay) - else: - logger.error( - "Failed to connect to local tunnel endpoint after max retries" - ) - raise TunneldConnectionError() - - raise TunneldConnectionError() - - def collect_active_tunnels() -> Dict[str, dict]: - active: Dict[str, dict] = {} - for interface, tunnel_task in self._tunneld_core.tunnel_tasks.items(): - if tunnel_task is None or tunnel_task.tunnel is None or tunnel_task.udid is None: - continue - tunnel = tunnel_task.tunnel - active[str(interface)] = { - "interface": str(interface), - "udid": tunnel_task.udid, - "address": getattr(tunnel, "address", None), - "port": getattr(tunnel, "port", None), - "transport": getattr(tunnel, "protocol", None), - } - return active - - """Tunnel Watcher""" - - async def handle_tunnel_drop(disconnected: list[dict]) -> None: - disconnected_udids: set[str] = set() - for item in disconnected: - udid = item.get("udid") - if isinstance(udid, str) and udid: - disconnected_udids.add(udid) - if not disconnected_udids: - return - current_active = collect_active_tunnels() - active_udids: set[str] = set() - for item in current_active.values(): - udid = item.get("udid") - if isinstance(udid, str) and udid: - active_udids.add(udid) - primary_disconnected = self.context.udid in disconnected_udids if self.context.udid else False - no_tunnels_left = len(current_active) == 0 - if not primary_disconnected and not no_tunnels_left: - return - logger.warning( - "Tunnel drop detected. primary_udid=%s disconnected_udids=%s active_udids=%s", - self.context.udid, - sorted(disconnected_udids), - sorted(active_udids), - ) - if self.context.tunnel is not None: - with suppress(Exception): - await self.context.tunnel.close() - self.context.tunnel = None - if no_tunnels_left: - self.context.udid = None - if self.context.simulation_active: - await pause_simulation_queue() - await self.context.sio.emit( - "appError", - { - "type": "tunnel_disconnected", - "message": "Tunnel dropped. Simulation paused until reconnection.", - "disconnected_udids": sorted(disconnected_udids), - }, - namespace="/", - ) - await end_icloud_monitor() - - async def tunnel_watcher_loop() -> None: - previous = collect_active_tunnels() - while True: - try: - await asyncio.sleep(1) - current = collect_active_tunnels() - added_keys = set(current.keys()) - set(previous.keys()) - removed_keys = set(previous.keys()) - set(current.keys()) - added = [current[k] for k in sorted(added_keys)] - removed = [previous[k] for k in sorted(removed_keys)] - for item in added: - try: - logger.info( - "Tunnel discovered interface=%s udid=%s address=%s port=%s transport=%s", - item.get("interface"), - item.get("udid"), - item.get("address"), - item.get("port"), - item.get("transport"), - ) - await safe_sio_emit("tunnel_device_connected", item) - logger.info(f"Current udid: %s, new udid: %s", - self.context.udid, item.get("udid")) - if self.context.udid is None: - self.context.udid = item.get("udid") - device_name = await get_device_name() - selected_device_name = self.context.device_name - if selected_device_name and device_name != selected_device_name: - wrong_udid = self.context.udid - self._tunneld_core.cancel( - udid=item.get("udid")) - logger.warning( - "Tunnel established to wrong device. Dropping tunnel. wrong_udid=%s for device: %s", - wrong_udid, selected_device_name) - self.context.udid = None - await safe_sio_emit( - "appError", - { - "type": "tunnel_wrong_device", - "message": "Tunnel established to wrong device. Dropping tunnel.", - "wrong_udid": wrong_udid, - "device_name": device_name, - }, - ) - await get_tun(item.get("udid"), max_retries=10, retry_delay=1.0) - except Exception: - logger.exception( - "Tunnel watcher failed while handling added tunnel for udid=%s", - item.get("udid"), - ) - - if removed: - for item in removed: - try: - logger.warning( - "Tunnel disconnected interface=%s udid=%s address=%s port=%s", - item.get("interface"), - item.get("udid"), - item.get("address"), - item.get("port"), - ) - await safe_sio_emit("tunnel_device_disconnected", item) - except Exception: - logger.exception( - "Tunnel watcher failed while emitting disconnection for udid=%s", - item.get("udid"), - ) - await handle_tunnel_drop(removed) - - previous = current - except asyncio.CancelledError: - raise - except Exception: - logger.exception("Tunnel watcher loop crashed; continuing") - - async def start_tunnel_watcher() -> None: - if self.context.tunnel_watcher_task is None or self.context.tunnel_watcher_task.done(): - if self.context.tunnel_watcher_task is not None and self.context.tunnel_watcher_task.done(): - with suppress(Exception): - exc = self.context.tunnel_watcher_task.exception() - if exc is not None: - logger.error( - "Previous tunnel watcher task exited with error: %s", exc) - self.context.tunnel_watcher_task = asyncio.create_task( - tunnel_watcher_loop(), - name="tunnel-task-watcher", - ) - - async def end_tunnel_watcher() -> None: - if self.context.tunnel_watcher_task is not None: - self.context.tunnel_watcher_task.cancel() - with suppress(asyncio.CancelledError): - await self.context.tunnel_watcher_task - self.context.tunnel_watcher_task = None - - """Helper Functions""" - - def parse_data(d): - mydict = {} - for key, value in d.items(): - if isinstance(value, dict): - parse_data(value) - elif ( - isinstance(value, str) - or isinstance(value, int) - or isinstance(value, float) - or isinstance(value, bool) - or isinstance(value, list) - ): - mydict[key] = value - elif isinstance(value, bytes): - mydict[key] = 'BYTE DATA' - else: - mydict[key] = "" - mydict[key] = value - return mydict - - async def get_device_name(): - if self.context.tunnel is None: - await get_tun() - device_name = await self.context.tunnel.get_value(key='DeviceName') - return device_name - - async def safe_sio_emit(event: str, payload: dict, timeout_seconds: float = 2.0) -> None: - try: - await asyncio.wait_for( - self.context.sio.emit(event, payload, namespace="/"), - timeout=timeout_seconds, - ) - except TimeoutError: - logger.warning("Socket.IO emit timed out for event=%s", event) - except Exception: - logger.exception("Socket.IO emit failed for event=%s", event) - - def parse_delay_seconds(raw_delay) -> int: - if raw_delay is None: - return 0 - if isinstance(raw_delay, bool): - raise ValueError("delay must be a non-negative number") - parsed = float(raw_delay) - if math.isnan(parsed) or math.isinf(parsed) or parsed < 0: - raise ValueError("delay must be a non-negative finite number") - return int(parsed) - - """Host power down functions""" - - async def device_reboot(delay): - """Reboot the device""" - logger.info("Reboot initiated with delay %s") - await asyncio.sleep(delay) - os.system("shutdown -r now") - - async def device_shutdown(delay): - """Shutdown the device""" - logger.info("Shutdown initiated with delay %s") - await asyncio.sleep(delay) - os.system("shutdown -h now") - - """iCloud Find My Monitor""" - - async def start_icloud_monitor(): - """Start Apple iCloud Find My Monitor to retrieve actual reported device location""" - logger.info("iCloud monitor start requested") - self.context.icloud_monitor_enabled = True - if ( - self.context.icloud_monitor_task is None - or self.context.icloud_monitor_task.done() - ): - self.context.icloud_monitor_task = asyncio.create_task( - self.context.icloud_monitor.run_monitor(interval=30), - name="icloud-monitor-producer", - ) - logger.info("iCloud monitor producer task started") - if ( - self.context.icloud_consumer_task is None - or self.context.icloud_consumer_task.done() - ): - self.context.icloud_consumer_task = asyncio.create_task( - consume_icloud_updates(), - name="icloud-monitor-consumer", - ) - logger.info("iCloud monitor consumer task started") - - async def consume_icloud_updates(): - while True: - updated_location = await self.context.fmf_queue.get() - try: - if self.context.fmf_location != updated_location: - self.context.fmf_location = updated_location - await self.context.sio.emit( - "fmf_update", updated_location.model_dump(), namespace="/" - ) - finally: - self.context.fmf_queue.task_done() - - async def refresh_icloud_location() -> dict: - """Fetch one iCloud location update, with or without monitor loop running.""" - logger.info("iCloud monitor refresh requested") - try: - updated_location = await self.context.icloud_monitor.refresh_location() - except Exception as e: - logger.exception("Failed to refresh iCloud location: %s", e) - return { - "command_status": "ERROR", - "command_class": "icloud_monitor", - "command": "refresh", - "message": "Failed to refresh iCloud location", - "error": type(e).__name__, - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - - if updated_location is None: - return { - "command_status": "OK", - "command_class": "icloud_monitor", - "command": "refresh", - "location_found": False, - "location_updated": False, - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - - location_updated = self.context.fmf_location != updated_location - self.context.fmf_location = updated_location - await self.context.sio.emit( - "fmf_update", updated_location.model_dump(), namespace="/" - ) - return { - "command_status": "OK", - "command_class": "icloud_monitor", - "command": "refresh", - "location_found": True, - "location_updated": location_updated, - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - "fmf_location": updated_location, - } - - async def end_icloud_monitor(): - logger.info("iCloud monitor stop requested") - self.context.icloud_monitor_enabled = False - self.context.icloud_monitor.stop() - if self.context.icloud_monitor_task is not None: - self.context.icloud_monitor_task.cancel() - with suppress(asyncio.CancelledError): - await self.context.icloud_monitor_task - self.context.icloud_monitor_task = None - if self.context.icloud_consumer_task is not None: - self.context.icloud_consumer_task.cancel() - with suppress(asyncio.CancelledError): - await self.context.icloud_consumer_task - self.context.icloud_consumer_task = None - - def is_icloud_monitor_running() -> bool: - return ( - self.context.icloud_monitor_task is not None - and not self.context.icloud_monitor_task.done() - and self.context.icloud_consumer_task is not None - and not self.context.icloud_consumer_task.done() - ) - - """ Queue Functions""" - - async def start_simulation_queue(): - if ( - self.context.simulation_task is None - or self.context.simulation_task.done() - ): - await start_icloud_monitor() - self.context.simulation_active = True - self.context.simulation_task = asyncio.create_task( - start_queue_worker(), - name="location-simulation-worker", - ) - data = { - "command_status": "OK", - "command_class": "simulation_control", - "command": "start", - "message": "Simulation started", - "data": { - "simulation_queue": get_simulation_status(), - }, - } - else: - data = { - "command_status": "ERROR", - "command_class": "simulation_control", - "command": "start", - "message": "Simulation already running", - "data": { - "simulation_queue": get_simulation_status(), - }, - } - return data - - async def start_queue_worker(): - """Start Simulation Queue Worker""" - logger.info("Starting location simulation worker...") - self.context.simulation_active = True - self.context.simulation_queue_state = "RUNNING" - try: - if self.context.test_mode: - logger.info("Simulation worker: test mode enabled") - locate_simulation = LocationSimulationQueue(None, self.context) - await locate_simulation.play_queue() - return - - if self.context.udid is None: - active_udids = sorted( - { - t.udid - for t in self._tunneld_core.tunnel_tasks.values() - if t.udid is not None and t.tunnel is not None - } - ) - if len(active_udids) == 1: - self.context.udid = active_udids[0] - logger.info( - "Simulation worker: auto-selected udid=%s from active tunnel", - self.context.udid, - ) - elif len(active_udids) == 0: - logger.error( - "Simulation worker: no active tunnel with udid available" - ) - await self.context.sio.emit( - "appError", - { - "type": "simulation_no_tunnel", - "message": "No active tunnel found. Start tunnel first.", - }, - namespace="/", - ) - return - else: - logger.error( - "Simulation worker: multiple active tunnels; explicit udid required: %s", - active_udids, - ) - await self.context.sio.emit( - "appError", - { - "type": "simulation_udid_required", - "message": "Multiple active tunnels found; provide udid in start_simulate_location.", - "udids": active_udids, - }, - namespace="/", - ) - return - - logger.info( - "Simulation worker: acquiring tunnel (udid=%s)", self.context.udid - ) - - tun = await get_tun(self.context.udid) - logger.info( - "Simulation worker: tunnel acquired, connecting DVT provider" - ) - dvt_provider = DvtProvider(tun) - dvt = await asyncio.wait_for( - dvt_provider.__aenter__(), timeout=DVT_CONNECT_TIMEOUT_SECONDS - ) - logger.info( - "Simulation worker: DVT provider connected, starting queue playback" - ) - try: - async with LocationSimulationQueue( - dvt, self.context - ) as locate_simulation: - await locate_simulation.play_queue() - finally: - logger.info("Simulation worker: closing DVT provider") - await dvt_provider.__aexit__(None, None, None) - logger.info("Simulation worker: DVT provider closed") - except TimeoutError: - logger.error( - "Simulation worker timeout. tunnel_timeout=%ss dvt_timeout=%ss udid=%s", - TUNNEL_ACQUIRE_TIMEOUT_SECONDS, - DVT_CONNECT_TIMEOUT_SECONDS, - self.context.udid, - ) - await self.context.sio.emit( - "appError", - { - "type": "simulation_timeout", - "udid": self.context.udid, - "tunnel_timeout_seconds": TUNNEL_ACQUIRE_TIMEOUT_SECONDS, - "dvt_timeout_seconds": DVT_CONNECT_TIMEOUT_SECONDS, - }, - namespace="/", - ) - except Exception: - logger.exception("Simulation worker crashed") - await self.context.sio.emit( - "appError", - { - "type": "simulation_crash", - "udid": self.context.udid, - "udid": self.context.udid, - "error": traceback.format_exc(), - }, - namespace="/", - ) - finally: - self.context.simulation_active = False - self.context.simulation_task = None - await end_icloud_monitor() - - async def add_location_to_simulation_queue(data): - loc_id = str(uuid.uuid4()) - latitude = ( - data.get("latitude") - if isinstance(data, dict) - else getattr(data, "latitude", None) - ) - longitude = ( - data.get("longitude") - if isinstance(data, dict) - else getattr(data, "longitude", None) - ) - delay = ( - data.get("delay", 0) - if isinstance(data, dict) - else getattr(data, "delay", 0) - ) - address = ( - data.get("address") - if isinstance(data, dict) - else getattr(data, "address", None) - ) - try: - delay = parse_delay_seconds(delay) - except ValueError as e: - return {"command_status": "ERROR", "command_class": "simulation_control", "command": "add", "message": str(e)} - if latitude is not None and longitude is not None: - logger.info( - "Adding location %s (%s, %s) with %s delay to the queue", - loc_id, - latitude, - longitude, - delay, - ) - accrued_delay = 0 - current_loc_id = self.context.get_current_loc_id() - if self.context.simulation_queue_data and len(self.context.simulation_queue_order) > 1: - current_index = get_item_index( - current_loc_id) if current_loc_id else 0 - remaining_items = self.context.simulation_queue_order[current_index + 1:] - accrued_delay = sum(parse_delay_seconds( - self.context.simulation_queue_data[loc_id]['delay']) for loc_id in remaining_items if loc_id in self.context.simulation_queue_data) - accrued_delay = accrued_delay + \ - parse_delay_seconds(self.context.next_move) - - now_time = datetime.now(timezone.utc) - new_time = ( - now_time - + timedelta(seconds=accrued_delay) - + timedelta(seconds=delay) - ) - start_time = new_time.isoformat() -# rev_geocode = await self.context.reverse_geocode.get_address(latitude, longitude) -# if rev_geocode: -# address = rev_geocode -# else: -# address = f"{latitude}, {longitude}" - location_item = { - "loc_id": loc_id, - "latitude": latitude, - "longitude": longitude, - "delay": delay, - "status": "queued", - "start": start_time, - "address": address, - } - resp = { - "command_status": "OK", - "command_class": "simulation_control", - "command": "add", - "message": f"Location {loc_id} added to the queue", - "data": { - "simulation_queue": get_simulation_status(), - "location_item": location_item, - }, - } - add_item(loc_id, location_item) - await enqueue_next_simulation_item() - logger.info("Location %s added to the queue", loc_id) - else: - logger.error("Invalid location data: %s", data) - resp = {"command_status": "ERROR", - "command_class": "simulation_control", - "command": "add", - "message": "Invalid location data", "data": data} - return resp - - async def delete_location_from_simulation_queue(data): - loc_id = ( - data.get("loc_id") - if isinstance(data, dict) - else getattr(data, "loc_id", None) - ) - if loc_id is not None: - if not loc_id in self.context.simulation_queue_order: - resp = { - "command_status": "ERROR", - "command_class": "simulation_control", - "command": "delete", - "message": f"Location {loc_id} not found in the queue", - "data": { - "simulation_queue": get_simulation_status(), - "location_item": self.context.simulation_queue_data[loc_id], - }, - } - logger.info("Deleting location %s from the queue", loc_id) - await delete_item(loc_id) - resp = { - "command_status": "OK", - "command_class": "simulation_control", - "command": "delete", - "message": f"Location {loc_id} deleted from the queue", - "data": { - "simulation_queue": get_simulation_status(), - "location_item": self.context.simulation_queue_data[loc_id], - }, - } - else: - resp = { - "command_status": "ERROR", - "command_class": "simulation_control", - "command": "delete", - "message": "Invalid location data", - "data": { - "simulation_queue:": get_simulation_status(), - }, - } - return resp - - async def pause_simulation_queue(): - """Pauses asyncio.Queue playback""" - self.context.simulation_queue_state = "PAUSED" - - async def resume_simulation_queue(): - """Resumes asyncio.simulation_queue playback""" - self.context.simulation_queue_state = "RUNNING" - await update_queue_times() - - async def advance_simulation_queue(): - current_loc_id = self.context.get_current_loc_id() - self.context.simulation_queue_data[current_loc_id]["delay"] = 0 - await update_queue_times() - - async def update_queue_times(): - current_loc_id = self.context.get_current_loc_id() - current_index = get_item_index( - current_loc_id) if current_loc_id else 0 - remaining_items = self.context.simulation_queue_order[current_index + 1:] - new_delay = 0 - now_time = datetime.now(timezone.utc) - for item in remaining_items: - item_delay = parse_delay_seconds( - self.context.simulation_queue_data[item].get("delay")) or 0 - new_delay += item_delay - new_time = ( - now_time + timedelta(seconds=new_delay)).isoformat() - logger.info("Updating queue time for %s to %s", item, new_time) - self.context.simulation_queue_data[item]["start"] = new_time - await update_queue_data() - - async def update_queue_data(): - await self.context.sio.emit("queue_data_update", get_simulation_status(), namespace="/") - - async def empty_simulation_queue(): - """Empties all items from an asyncio.Queue.""" - logger.info("Clearing location simulation queue...") - q = self.context.simulation_queue - while not q.empty(): - try: - item = q.get_nowait() - if isinstance(item, str): - self.context.simulation_queue_pending_ids.discard(item) - q.task_done() - logger.info("Discarding item from queue: %s", item) - except asyncio.QueueEmpty: - break - self.context.simulation_queue_pending_ids.clear() - reset_queue() - - def add_item(item_id, payload): - self.context.simulation_queue_data[item_id] = payload - self.context.simulation_queue_order.append(item_id) - - def remove_item(item_id): - if item_id in self.context.simulation_queue_order: - self.context.simulation_queue_order.remove(item_id) - del self.context.simulation_queue_data[item_id] - - def reset_queue(): - self.context.simulation_queue_data = {} - self.context.simulation_queue_order = [] - self.context.simulation_queue_deleted_items = [] - self.context.set_current_loc_id(None) - - def remove_future_items(): - current_loc_id = self.context.get_current_loc_id() - if current_loc_id and self.context.simulation_active and current_loc_id in self.context.simulation_queue_data: - self.context.simulation_queue_order = [current_loc_id] - self.context.simulation_queue_data = { - current_loc_id: self.context.simulation_queue_data[current_loc_id] - } - else: - self.context.simulation_queue_data = {} - self.context.simulation_queue_order = [] - self.context.simulation_queue_deleted_items = [] - self.context.set_current_loc_id(None) - - - async def delete_item(item_id): - if item_id in self.context.simulation_queue_order: - self.context.simulation_queue_data[item_id]["status"] = "deleted" - self.context.simulation_queue_order.remove(item_id) - self.context.simulation_queue_deleted_items.append(item_id) - await update_queue_times() - - def clear_future_items(): - current_loc_id = self.context.get_current_loc_id() - current_index = get_item_index( - current_loc_id) if current_loc_id else 0 - for item in self.context.simulation_queue_order[current_index + 1:]: - self.context.simulation_queue_data[item]["status"] = "deleted" - self.context.simulation_queue_order.remove(item) - self.context.simulation_queue_deleted_items.append(item) - - def get_item(item_id): - return self.context.simulation_queue_data[item_id] - - def update_item(item_id, **updates): - if item_id in self.context.simulation_queue_data: - self.context.simulation_queue_data[item_id].update(updates) - - def get_item_index(item_id): - return self.context.simulation_queue_order.index(item_id) - - def get_item_id_by_index(index): - return self.context.simulation_queue_order[index] - - def get_items_in_order(): - return [self.context.simulation_queue_data[i] for i in self.context.simulation_queue_order if self.context.simulation_queue_data[i].get("status") != "deleted"] - - async def enqueue_next_simulation_item() -> Optional[str]: - if self.context.simulation_queue_state == "SHUTDOWN": - return None - stale_pending_ids = { - pending_id - for pending_id in self.context.simulation_queue_pending_ids - if not isinstance(self.context.simulation_queue_data.get(pending_id), dict) - or self.context.simulation_queue_data[pending_id].get("status") != "queued" - } - if stale_pending_ids: - for stale_id in stale_pending_ids: - self.context.simulation_queue_pending_ids.discard(stale_id) - logger.info( - "Cleared stale pending queue ids before enqueue: %s", - sorted(stale_pending_ids), - ) - if self.context.simulation_queue_pending_ids: - return None - for item_id in self.context.simulation_queue_order: - if item_id in self.context.simulation_queue_pending_ids: - continue - item = self.context.simulation_queue_data.get(item_id) - if not isinstance(item, dict): - continue - if item.get("status") != "queued": - continue - self.context.simulation_queue_pending_ids.add(item_id) - await self.context.simulation_queue.put(item_id) - logger.info("Scheduled queue item %s", item_id) - return item_id - return None - - async def end_simulation_queue(): - logger.info("End location simulation request") - end_task = asyncio.create_task( - end_simulation_worker(), name="end-simulation-worker" - ) - result = await end_task - return result - - async def end_simulation_worker() -> bool: - """Ends asyncio.Queue playback and closes tunnel""" - logger.info("End location simulation request") - try: - q = self.context.simulation_queue - self.context.simulation_queue_state = "SHUTDOWN" - - # Drain pending queue entries. - while not q.empty(): - try: - item = q.get_nowait() - if isinstance(item, str): - self.context.simulation_queue_pending_ids.discard(item) - q.task_done() - logger.info("Discarding item from queue: %s", item) - except asyncio.QueueEmpty: - break - - # Wake queue consumers blocked on queue.get(). - await q.put(None) - - reset_queue() - - if self.context.simulation_task is not None and not self.context.simulation_task.done(): - try: - await asyncio.wait_for(self.context.simulation_task, timeout=5) - except TimeoutError: - logger.warning( - "Simulation worker did not stop in time; canceling task") - self.context.simulation_task.cancel() - with suppress(asyncio.CancelledError): - await self.context.simulation_task - - if not self.context.test_mode and self.context.tunnel is not None: - async with ( - DvtProvider(self.context.tunnel) as dvt, - LocationSimulationQueue(dvt, self.context) as locate_simulation, - ): - await locate_simulation.clear() - self.context.simulation_active = False - self.context.simulation_task = None - self.context.next_move = None - self.context.set_current_loc_id(None) - self.context.simulation_queue_state = "STOPPED" - # Recreate queue to discard sentinel wakeup items and unblock clean restarts. - self.context.simulation_queue = asyncio.Queue() - self.context.simulation_queue_pending_ids = set() - await end_icloud_monitor() - return True - except Exception as e: - logger.error(f"Error ending simulation queue: {e}") - return False - - """Switches""" - - def toggle_test_mode() -> dict: - self.context.test_mode = not self.context.test_mode - return {"test_mode": self.context.test_mode} - - def toggle_gps_noise() -> dict: - self.context.simulation_noise = not self.context.simulation_noise - return {"simulation_noise": self.context.simulation_noise} - - def get_simulation_status() -> dict: - resp = { - "active": self.context.simulation_active, - "data": self.context.simulation_queue_data, - "order": self.context.simulation_queue_order, - "deleted_items": self.context.simulation_queue_deleted_items, - "state": self.context.simulation_queue_state, - "gps_noise": self.context.simulation_noise, - "worker_task": self.context.simulation_task.get_name() if self.context.simulation_task else None, - "test_mode": self.context.test_mode, - } - return resp - - def get_status() -> dict: - current_loc_id = self.context.get_current_loc_id() - current_item = self.context.simulation_queue_data.get( - current_loc_id) if current_loc_id else None - current_start = ( - current_item.get("start") - if isinstance(current_item, dict) - else getattr(current_item, "start", None) - ) - current_latitude = current_item.get( - "latitude") if isinstance(current_item, dict) else None - current_longitude = current_item.get( - "longitude") if isinstance(current_item, dict) else None - data = { - "connected_clients": list(self.context.connected_clients), - "current_location": { - "loc_id": current_loc_id, - "longitude": current_longitude, - "latitude": current_latitude, - "start": current_start, - }, - "device_name": self.context.device_name, - "fmf_location": self.context.fmf_location, - "icloud": { - "consumer_queue": self.context.fmf_queue.qsize() if self.context.fmf_queue else 0, - "consumer_task": self.context.icloud_consumer_task.done() if self.context.icloud_consumer_task else False, - "monitor_task": self.context.icloud_monitor_task.done() if self.context.icloud_monitor_task else False, - "monitor_enabled": self.context.icloud_monitor_enabled, - "monitor_running": is_icloud_monitor_running(), - }, - "tunnel_watcher_running": True if self.context.tunnel_watcher_task and not self.context.tunnel_watcher_task.done() else False, - "next_move": self.context.next_move, - "simulation_queue": get_simulation_status(), - "tunnel": self.context.tunnel.service.address[0] if self.context.tunnel else None, - "udid": self.context.udid, - } - return data - - async def get_reverse_geocode(data): - latitude = float( - data.get("latitude", 999) - if isinstance(data, dict) - else getattr(data, "latitude", 999) - ) - longitude = float( - data.get("longitude", 999) - if isinstance(data, dict) - else getattr(data, "longitude", 999) - ) - if latitude != 999 and longitude != 999: - rev_geocode = await self.context.reverse_geocode.get_address(latitude, longitude) - return rev_geocode - else: - return None - - async def get_favorites(): - favorites = await self.context.reverse_geocode.get_favorites() - return favorites - - async def clear_favorite(data): - latitude = float( - data.get("latitude", 999) - if isinstance(data, dict) - else getattr(data, "latitude", 999) - ) - longitude = float( - data.get("longitude", 999) - if isinstance(data, dict) - else getattr(data, "longitude", 999) - ) - if latitude != 999 and longitude != 999: - clear_status = await self.context.reverse_geocode.clear_favorite(latitude, longitude) - if clear_status: - return "OK", "Favorite cleared" - else: - return "ERROR", "Failed to clear favorite" - else: - return "ERROR", "Invalid latitude and longitude" - - async def set_favorite(data): - favorite = ( - data.get("favorite") - if isinstance(data, dict) - else getattr(data, "favorite", None) - ) - if favorite is not None: - name = ( - favorite.get("name") - if isinstance(favorite, dict) - else getattr(favorite, "name", None) - ) - logger.info("Adding favorite: %s", name) - latitude = float( - favorite.get("latitude", 999) - if isinstance(favorite, dict) - else getattr(favorite, "latitude", 999) - ) - longitude = float( - favorite.get("longitude", 999) - if isinstance(favorite, dict) - else getattr(favorite, "longitude", 999) - ) - if latitude != 999 and longitude != 999 and favorite: - set_status = await self.context.reverse_geocode.set_favorite(latitude, longitude, favorite) - if set_status: - return "OK", "Favorite added" - else: - return "ERROR", "Failed to add favorite" - else: - return "ERROR", "Invalid latitude and longitude" - else: - return "ERROR", "Invalid favorite data" - - """ FastAPI HTTP Functions""" - - def generate_http_response( - data: dict, status_code: int = 200, media_type: str = "application/json" - ) -> fastapi.Response: - return fastapi.Response( - status_code=status_code, - media_type=media_type, - content=json.dumps(jsonable_encoder(data)), - ) - - """ Device Functions """ - """ name """ - @self._app.get("/device/name") - async def rsd_info(): - """Get rsd information""" - device_name = await get_device_name() - return generate_http_response(device_name) - - @self._app.get("/device/info") - async def device_info(): - """Get device information""" - tunnels = {} - for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items(): - if (active_tunnel.udid is None) or (active_tunnel.tunnel is None): - continue - if active_tunnel.udid not in tunnels: - tunnels[active_tunnel.udid] = {} - try: - lockdown = await create_using_usbmux( - serial=active_tunnel.udid, autopair=False - ) - tunnels[active_tunnel.udid] = parse_data( - lockdown.all_values) - except Exception as e: - logger.error( - f"Failed to create lockdown session for device { - active_tunnel.udid}: {e}" - ) - continue - return tunnels - - """ Tunnel Functions""" - @self._app.get("/") - async def list_tunnels() -> dict[str, list[dict]]: - """Retrieve the available tunnels and format them as {UUID: TUNNEL_ADDRESS}""" - tunnels = {} - for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items(): - if (active_tunnel.udid is None) or (active_tunnel.tunnel is None): - continue - if active_tunnel.udid not in tunnels: - tunnels[active_tunnel.udid] = [] - tunnels[active_tunnel.udid].append( - { - "tunnel-address": active_tunnel.tunnel.address, - "tunnel-port": active_tunnel.tunnel.port, - "interface": ip, - } - ) - return tunnels - - @self._app.get("/shutdown") - async def shutdown() -> fastapi.Response: - """Shutdown Tunneld""" - os.kill(os.getpid(), signal.SIGINT) - data = {"operation": "shutdown", "data": True, - "message": "Server shutting down..."} - return generate_http_response(data) - - @self._app.get("/clear-tunnels") - async def clear_tunnels() -> fastapi.Response: - """Clear all tunnels""" - self._tunneld_core.clear() - data = { - "operation": "clear_tunnels", - "data": True, - "message": "Cleared tunnels...", - } - return generate_http_response(data) - - @self._app.get("/cancel") - async def cancel_tunnel(udid: str) -> fastapi.Response: - """Cancel a tunnel""" - self._tunneld_core.cancel(udid=udid) - data = { - "operation": "cancel", - "udid": udid, - "data": True, - "message": f"tunnel {udid} Canceled ...", - } - return generate_http_response(data) - - @self._app.get("/hello") - async def hello() -> fastapi.Response: - data = {"message": "Hello, I'm alive"} - return generate_http_response(data) - - @self._app.get("/start-tunnel") - async def start_tunnel( - udid: Optional[str] = self.context.udid, - ip: Optional[str] = None, - connection_type: Optional[str] = None, - ) -> fastapi.Response: - udid_tunnels = [ - t.tunnel - for t in self._tunneld_core.tunnel_tasks.values() - if t.udid == udid and t.tunnel is not None - ] - if len(udid_tunnels) > 0: - self.context.udid = udid - data = { - "interface": udid_tunnels[0].interface, - "port": udid_tunnels[0].port, - "address": udid_tunnels[0].address, - } - return generate_http_response(data) - queue = asyncio.Queue() - created_task = False - try: - if not created_task and connection_type in ("usbmux", None): - task_identifier = f"usbmux-{udid}" - try: - async with await create_using_usbmux(udid) as lockdown: - service = await CoreDeviceTunnelProxy.create(lockdown) - task = asyncio.create_task( - self._tunneld_core.start_tunnel_task( - task_identifier, - service, - protocol=TunnelProtocol.TCP, - queue=queue, - ), - name=f"start-tunnel-task-{task_identifier}", - ) - self._tunneld_core.tunnel_tasks[task_identifier] = TunnelTask( - task=task, udid=udid - ) - created_task = True - except (ConnectionFailedError, InvalidServiceError, MuxException): - pass - if connection_type in ("usb", None): - for rsd in await get_rsds(udid=udid): - rsd_ip = rsd.service.address[0] - if ip is not None and rsd_ip != ip: - await rsd.close() - continue - task = asyncio.create_task( - self._tunneld_core.start_tunnel_task( - rsd_ip, - await create_core_device_tunnel_service_using_rsd(rsd), - queue=queue, - ), - name=f"start-tunnel-usb-{rsd_ip}", - ) - self._tunneld_core.tunnel_tasks[rsd_ip] = TunnelTask( - task=task, udid=rsd.udid - ) - created_task = True - if not created_task and connection_type in ("wifi", None): - for remotepairing in await get_remote_pairing_tunnel_services( - udid=udid - ): - remotepairing_ip = remotepairing.hostname - if ip is not None and remotepairing_ip != ip: - await remotepairing.close() - continue - task = asyncio.create_task( - self._tunneld_core.start_tunnel_task( - remotepairing_ip, remotepairing, queue=queue - ), - name=f"start-tunnel-wifi-{remotepairing_ip}", - ) - self._tunneld_core.tunnel_tasks[remotepairing_ip] = TunnelTask( - task=task, udid=remotepairing.remote_identifier - ) - created_task = True - except Exception as e: - return fastapi.Response( - status_code=501, - content=json.dumps( - { - "error": { - "exception": e.__class__.__name__, - "traceback": traceback.format_exc(), - } - } - ), - ) - if not created_task: - return fastapi.Response( - status_code=501, content=json.dumps({"error": "task not created"}) - ) - tunnel: Optional[TunnelResult] = await queue.get() - if tunnel is not None: - self.context.udid = udid - data = { - "interface": tunnel.interface, - "port": tunnel.port, - "address": tunnel.address, - } - return generate_http_response(data) - else: - return fastapi.Response( - status_code=404, - content=json.dumps( - {"error": "something went wrong during tunnel creation"} - ), - ) - - @self._app.get("/restart") - async def restart() -> fastapi.Response: - """Restart Tunneld""" - self._tunneld_core.clear() - await asyncio.sleep(2) - self._tunneld_core.start() - data = { - "operation": "restart-tunneld", - "data": True, - "message": "Restarting tunneld...", - } - return generate_http_response(data) - - """ iCloud Monitor""" - - @self._app.get("/icloud-monitor/start") - async def app_start_icloud_monitor() -> fastapi.Response: - await start_icloud_monitor() - data = { - "command_status": "OK", - "command_class": "icloud_monitor", - "command": "start", - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - return generate_http_response(data) - - @self._app.get("/icloud-monitor/stop") - async def app_stop_icloud_monitor() -> fastapi.Response: - await end_icloud_monitor() - data = { - "command_status": "OK", - "command_class": "icloud_monitor", - "command": "stop", - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - return generate_http_response(data) - - @self._app.get("/icloud-monitor/status") - async def app_icloud_monitor_status() -> fastapi.Response: - data = { - "command_status": "OK", - "command_class": "icloud_monitor", - "command": "status", - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - return generate_http_response(data) - - @self._app.get("/icloud-monitor/refresh") - async def app_refresh_icloud_monitor() -> fastapi.Response: - data = await refresh_icloud_location() - return generate_http_response(data) - - """Simulation Functions""" - """ start, add, clear, pause, resume, end, status """ - - @self._app.get("/simulation/start") - async def app_start_simulation() -> fastapi.Response: - data = await start_simulation_queue() - return generate_http_response(data) - - @self._app.post("/simulation/add") - async def app_add_location(data: SimulationRequestData) -> fastapi.Response: - """Add a location to the simulation queue""" - logger.info("Request to add new location to queue") - resp = await add_location_to_simulation_queue(data) - return generate_http_response(resp) - - @self._app.get("/simulation/clear") - async def app_clear_queue() -> fastapi.Response: - """Clear the simulation queue""" - logger.info("Simulation Start Requested ") - clear_future_items() - data = {"command_status": "OK", "command_class": "simulation_control", "command": "clear", "message": "Simulation cleared"} - return generate_http_response(data) - - @self._app.get("/simulation/pause") - async def app_pause_queue() -> fastapi.Response: - """Pause the simulation queue""" - await pause_simulation_queue() - data = {"command_status": "OK", "command_class": "simulation_control", "command": "pause", "message": "Simulation paused"} - return generate_http_response(data) - - @self._app.get("/simulation/resume") - async def app_resume_queue() -> fastapi.Response: - """Resume the simulation queue""" - await resume_simulation_queue() - data = {"command_status": "OK", "command_class": "simulation_control", "command": "resume", "message": "Simulation resumed"} - return generate_http_response(data) - - @self._app.get("/simulation/end") - async def app_end_simulation() -> fastapi.Response: - """End the simulation queue""" - data = end_simulation_queue() - return generate_http_response(data) - - @self._app.get("/simulation/test") - async def app_simulation_test_mode() -> fastapi.Response: - """Enable test mode for the simulation queue""" - response = {} - before_toggle = self.context.test_mode - response['data'] = toggle_test_mode() - response['command_status'] = "OK" - response['command_class'] = "simulation_control" - response['command'] = "test_mode" - response['message'] = f"Test mode toggled from { - before_toggle} to {self.context.test_mode}" - return generate_http_response(response) - - @self._app.get("/simulation/noise") - async def app_simulation_noise() -> fastapi.Response: - response = {} - before_toggle = self.context.simulation_noise - response['data'] = toggle_gps_noise() - response['command_status'] = "OK" - response['command_class'] = "simulation_control" - response['command'] = "noise" - response['message'] = f"GPS noise toggled from { - before_toggle} to {self.context.simulation_noise}" - return generate_http_response(response) - - """Status Functions""" - - @self._app.get("/status/rsd") - async def app_rsd_info(): - """Get rsd information""" - rsd_info = {} - if self.context.tunnel is None: - await get_tun() - if self.context.tunnel is not None: - rsd_info = self.context.tunnel.peer_info - return generate_http_response(rsd_info) - - @self._app.get("/status/context") - async def app_context_status() -> fastapi.Response: - data = get_status() - return generate_http_response(data) - - @self._app.post("/rev_geocode") - async def app_proxy_osm(data: LatLng): - logger.info("OSM Proxy Request, data: %s", data) - rev_geocode = await get_reverse_geocode(data) - return generate_http_response(rev_geocode) - - @self._app.get("/ors/status") - async def app_ors_status(request: Request) -> fastapi.Response: - logger.info("request: %s", request) - target_url = "https://ors.intrepidnet.org/ors/v2/status" - async with httpx.AsyncClient() as client: - # Forward the request to the external service - response = await client.get(target_url, params=request.query_params) - # Return the response content and status code back to the original client - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers) - ) - - @self._app.get("/ors/health") - async def app_ors_status(request: Request) -> fastapi.Response: - logger.info("request: %s", request) - target_url = "https://ors.intrepidnet.org/ors/v2/health" - async with httpx.AsyncClient() as client: - # Forward the request to the external service - response = await client.get(target_url, params=request.query_params) - # Return the response content and status code back to the original client - return Response( - content=response.content, - status_code=response.status_code, - headers=dict(response.headers) - ) - - @self._app.post("/ors/proxy/{full_path:path}") - async def app_proxy_ors(full_path: str, request: Request): - logger.info("request: %s", request) - body = await request.json() - headers = dict(request.headers) - method = request.method - url = "https://ors.intrepidnet.org/" + full_path - logger.info("body: %s", body) - logger.info("headers: %s", headers) - logger.info("method: %s", method) - logger.info("url: %s", url) - - async with httpx.AsyncClient() as client: - response = await client.request(method, url, json=body) - return response.json() - - """ Socket.IO Functions""" - - async def sio_send_status(sid): - """Send Current Status""" - await self.context.sio.emit( - "status", jsonable_encoder(get_status()), namespace="/", to=sid - ) - - """Socket.IO Connection Events""" - - @self.context.sio.event - async def connect(sid, environ): - """Client connection event handler.""" - self.context.connected_clients.add(sid) - logger.info("Client connected: %s", sid) - await sio_send_status(sid) - return "%s connected" % sid - - @self.context.sio.event - async def disconnect(sid): - """Client disconnection event handler.""" - self.context.connected_clients.discard(sid) - logger.info("Client disconnected: %s", sid) - - """ Socket.IO Request Events """ - - @self.context.sio.event - async def request_update(sid): - status_update = jsonable_encoder(get_status()) - logger.info("Update request from %s sending %s", - sid, status_update) - return status_update - - @self.context.sio.event - async def message(sid, data): - logger.info("Received message from %s: %s", sid, data) - return True, "Message received" - - # await self.context.sio.emit("message", f"Received message from {sid}: {data}", namespace="/") - - """ Device Control""" - - @self.context.sio.event - async def device_control(sid, data): - """Device Control""" - command = ( - data.get("command") - if isinstance(data, dict) - else getattr(data, "command", None) - ) - delay = ( - data.get("delay") - if isinstance(data, dict) - else getattr(data, "delay", None) - ) - if delay is None: - delay = 5 - match command: - case "shutdown": - """ Shutdown the device""" - logger.info( - "Shutdown command received from %s with delay %s", sid, delay - ) - await device_shutdown(delay) - return { - "command_status": "OK", - "command_class": "device_control", - "command": "shutdown", - "message": f"Device shutdown initiated with {delay} seconds delay", - } - - case "reboot": - """ Reboot the device""" - logger.info( - "Reboot command received from %s with delay %s", sid, delay - ) - await device_reboot(delay) - return { - "command_status": "OK", - "command_class": "device_control", - "command": "reboot", - "message": f"Device reboot initiated with {delay} seconds delay", - } - - case _: - return { - "command_status": "ERROR", - "command_class": "device_control", - "command": command, - "message": f"Invalid command: {command}", - } - - @self.context.sio.event - async def simulation_control(sid, data): - """Simulation Control""" - command = ( - data.get("command") - if isinstance(data, dict) - else getattr(data, "command", None) - ) - logger.info( - "Simulation Control command: %s requested from %s", command, sid - ) - try: - match command: - case "next": - await advance_simulation_queue() - resp = { - "command": command, - "command_class": "simulation_control", - "command_status": "OK", - "message": "Advanced simulation queue", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "test-mode": - toggle_test_mode() - resp = { - "command": command, - "command_class": "simulation_control", - "command_status": "OK", - "message": "test-mode toggled", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "add": - """ Add a location to the simulation queue""" - resp = await add_location_to_simulation_queue(data) - return resp - - case "delete": - """ Delete a location to the simulation queue""" - resp = await delete_location_from_simulation_queue(data) - return resp - - case "clear": - """ Clear the simulation queue""" - clear_future_items() - resp = { - "command": command, - "command_class": "simulation_control", - "command_status": "OK", - "message": "Simulation cleared", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "reset": - """ Reset the simulation queue""" - reset_queue() - resp = { - "command": command, - "command_class": "simulation_control", - "command_status": "OK", - "message": "Simulation queue reset", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "pause": - """ Pause the simulation queue""" - await pause_simulation_queue() - resp = { - "command": command, - "command_class": "simulation_control", - "command_status": "OK", - "message": "Simulation paused", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "resume": - """ Resume the simulation queue""" - await resume_simulation_queue() - resp = { - "command": command, - "command_status": "OK", - "command_class": "simulation_control", - "message": "Simulation resumed", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "end": - """ End the simulation queue""" - logger.info( - "End location simulation request from %s", sid) - cstat = "OK" if await end_simulation_queue() else "ERROR" - resp = { - "command_status": cstat, - "command_class": "simulation_control", - "command": command, - "message": "Simulation ended", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - - case "start": - """ Start the simulation queue""" - logger.info( - "Start location simulation request from %s", sid) - data = await start_simulation_queue() - return data - - case "gps-noise": - """ Toggle GPS noise""" - before_toggle = self.context.simulation_noise - toggle_gps_noise() - resp = { - "command_status": "OK", - "command_class": "simulation_control", - "command": command, - "message": f"GPS noise toggled from {before_toggle} to {self.context.simulation_noise}", - "data": { - "simulation_queue": get_simulation_status(), - } - } - return resp - case _: - logger.warning( - "Invalid command received from %s: %s", sid, command - ) - return {"command_status": "ERROR", "message": "Invalid command"} - finally: - logger.info("Simulation Control command: %s completed, sending status update...", command) - await sio_send_status(sid) - - @self.context.sio.event - async def location_item_update(sid, data): - """Location Item Control""" - loc_id = ( - data.get("loc_id") - if isinstance(data, dict) - else getattr(data, "loc_id", None) - ) - key = ( - data.get("key") - if isinstance(data, dict) - else getattr(data, "key", None) - ) - value = ( - data.get("value") - if isinstance(data, dict) - else getattr(data, "value", None) - ) - if loc_id and key and value: - old_val = self.context.simulation_queue_data[loc_id][key] - self.context.simulation_queue_data[loc_id][key] = value - if key == "delay": - await update_queue_times() - logger.info( - "Location Item Update: %s: %s changed from %s to %s", loc_id, key, old_val, value - ) - return {"command_status": "OK", "command": "update", "command_class": "location_item", "message": "Location Item Updated", "data": self.context.simulation_queue_data[loc_id]} - else: - return {"command_status": "ERROR", "command": "update", "command_class": "location_item", "message": "Invalid request, Location Item Unchanged", "data": self.context.simulation_queue_data[loc_id]} - - @self.context.sio.event - async def queue_order_update(sid, data): - logger.info("Queue order update received: %s", data) - new_simulation_queue_order = [] - new_order = ( - data.get("newOrder") - if isinstance(data, dict) - else getattr(data, "newOrder", None) - ) - if new_order: - for item in new_order: - if item in self.context.simulation_queue_data: - new_simulation_queue_order.append(item) - self.context.simulation_queue_order = new_simulation_queue_order - resp = { - "command_status": "OK", - "command": "queue_order_update", - "command_class": "queue_order", - "message": "Queue Order Updated", - "data": self.context.simulation_queue_order - } - else: - resp = { - "command_status": "ERROR", - "command": "queue_order_update", - "command_class": "queue_order", - "message": "Invalid request, Queue Order Unchanged", - "data": self.context.simulation_queue_order - } - return resp - - @self.context.sio.event - async def icloud_monitor_control(sid, data): - command = ( - data.get("command") - if isinstance(data, dict) - else getattr(data, "command", None) - ) - logger.info( - "iCloud Monitor control command: %s requested from %s", command, sid - ) - try: - match command: - case "start": - await start_icloud_monitor() - return { - "command": command, - "command_class": "icloud_monitor_control", - "command_status": "OK", - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - case "stop": - await end_icloud_monitor() - return { - "command": command, - "command_class": "icloud_monitor_control", - "command_status": "OK", - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - case "status": - return { - "command": command, - "command_class": "icloud_monitor_control", - "command_status": "OK", - "icloud_monitor_enabled": self.context.icloud_monitor_enabled, - "icloud_monitor_running": is_icloud_monitor_running(), - } - case "refresh": - data = await refresh_icloud_location() - return data - case _: - return { - "command": command, - "command_class": "icloud_monitor_control", - "command_status": "ERROR", - "message": "Invalid command", - } - finally: - await sio_send_status(sid) - - """ Tunnel Control """ - - @self.context.sio.event - async def tunneld_control(sid, data): - command = ( - data.get("command") - if isinstance(data, dict) - else getattr(data, "command", None) - ) - logger.info( - "Tunneld Control command: %s requested from %s", command, sid) - match command: - case "start": - """Start Tunneld""" - logger.info("Start tunneld request from %s: %s", sid, data) - try: - self._tunneld_core.start() - logger.info("Tunneld started successfully") - return { - "command_status": "OK", - "command_class": "tunneld_control", - "command": command, - "message": "Tunneld started successfully", - } - except Exception as e: - logger.error("Error starting tunneld: %s", e) - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "ERROR", - "message": f"Error starting tunneld: {e}", - } - case "restart": - """Restart Tunneld""" - logger.info("Restart tunneld request from %s: %s", sid, data) - try: - self._tunneld_core.clear() - await asyncio.sleep(2) - self._tunneld_core.start() - return { - "command_status": "OK", - "command_class": "tunneld_control", - "command": command, - "message": "Tunneld started successfully", - } - except Exception as e: - logger.error("Error restarting tunneld: %s", e) - return { - "command_status": "ERROR", - "command_class": "tunneld_control", - "command": command, - "message": f"Error starting tunneld: {e}", - } - - case "start-watcher": - """ Start Tunneld Watcher """ - logger.info( - "Start tunneld watcher request from %s: %s", sid, data) - await start_tunnel_watcher() - return { - "command_status": "OK", - "command_class": "tunneld_control", - "command": command, - "message": "Tunneld watcher started successfully", - } - - case "end-watcher": - """ End Tunneld Watcher """ - logger.info( - "End tunneld watcher request from %s: %s", sid, data) - await end_tunnel_watcher() - return { - "command_status": "OK", - "command_class": "tunneld_control", - "command": command, - "message": "Tunneld watcher stopped successfully", - } - - case "shutdown": - """Shutdown Tunneld""" - logger.info( - "Shutdown tunneld request from %s: %s", sid, data) - try: - os.kill(os.getpid(), signal.SIGINT) - return { - "command_status": "OK", - "command_class": "tunneld_control", - "command": command, - "message": "Server shutting down...", - } - except Exception as e: - logger.error("Error shutting down tunneld: %s", e) - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "ERROR", - "message": f"Error shutting down tunneld: {e}", - } - - case "clear": - """Clear all tunnels""" - logger.info("Clearing tunnels...") - try: - self._tunneld_core.clear() - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "OK", - "message": "Cleared tunnels...", - } - except Exception as e: - logger.error("Error clearing tunnels: %s", e) - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "ERROR", - "message": f"Error clearing tunnels: {e}", - } - - case "cancel": - """Cancel a tunnel""" - logger.info( - "Canceling tunnel request from %s: %s", sid, data) - try: - udid = ( - data.get("udid") - if isinstance(data, dict) - else getattr(data, "udid", self.context.udid) - ) - if udid is None: - udid = self.context.udid - self._tunneld_core.cancel(udid=udid) - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "OK", - "udid": udid, - "message": f"tunnel {udid} Canceled ...", - } - except Exception as e: - logger.error("Error canceling tunnel: %s", e) - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "ERROR", - "message": f"Error canceling tunnel: {e}", - } - - case _: - return { - "command": command, - "command_class": "tunneld_control", - "command_status": "ERROR", - "message": f"Unknown operation: {command}", - } - @self.context.sio.event - async def reverse_geocode(sid, data: LatLng): - logger.info("OSM Proxy Request from %s, data: %s", sid, data) - rev_geocode = await get_reverse_geocode(data) - return rev_geocode - - @self.context.sio.event - async def favorite_control(sid, data: dict): - command = ( - data.get("command") - if isinstance(data, dict) - else getattr(data, "command", None) - ) - logger.info( - "Favorite Control command: %s requested from %s", command, sid - ) - match command: - case "set": - """ Add a favorite location to database""" - cstat, cmessage = await set_favorite(data) - if cstat == "ERROR": - logger.error('Favorite set failed: %s', cmessage) - resp = { - "command": command, - "command_class": "favorite_control", - "command_status": cstat, - "message": cmessage, - "data": { - "favorites": await get_favorites(), - } - } - return resp - - case "clear": - """ Clear a favorite location from database""" - cstat, cmessage = await clear_favorite(data) - resp = { - "command": command, - "command_class": "favorite_control", - "command_status": cstat, - "message": cmessage, - "data": { - "favorites": await get_favorites(), - } - } - return resp - - case "get": - """ Get favorite locations from database""" - favs = await get_favorites() - if favs: - cstat = "OK" - cmessage = "Favorites retrieved successfully" - else: - cstat = "ERROR" - cmessage = "No favorites found" - resp = { - "command": command, - "command_class": "favorite_control", - "command_status": cstat, - "message": cmessage, - "data": { - "favorites": favs, - } - } - return resp - - case _: - return { - "command": command, - "command_class": "favorite_control", - "command_status": "ERROR", - "message": f"Invalid command: {command}", - } - - - + self.tunnel_service = TunnelService(self.context, self._tunneld_core) + self.icloud_service = ICloudMonitorService(self.context) + self.simulation_service = SimulationService( + self.context, + self.tunnel_service, + self.icloud_service, + ) + self.tunnel_service.bind_services( + self.simulation_service, + self.icloud_service, + ) + + self.context.icloud_monitor.sio = self.context.sio + self.context.icloud_monitor.get_client_sids = lambda: list( + self.context.connected_clients + ) + + self._asgi_app = socketio.ASGIApp( + self.context.sio, + self._vue_app, + on_startup=self._app_startup, + on_shutdown=self._app_shutdown, + ) + + self._register_socketio_events() + register_http_routes( + self._app, + self.context, + self.tunnel_service, + self.icloud_service, + self.simulation_service, + ) self._vue_app.include_router(self._app, prefix="/api") - self._vue_app.mount( - "/assets", StaticFiles(directory=self._vue_dist + "assets", html=True), name="vue") + self._mount_vue_app() - @self._vue_app.get("/{full_path:path}") - async def serve_vue_app(full_path: str): - return FileResponse(self._vue_dist + "index.html") + async def _app_startup(self) -> None: + logger.info("Application startup: starting tunneld core and watcher") + self._tunneld_core.start() + await self.tunnel_service.start_watcher() + + async def _app_shutdown(self) -> None: + logger.info("Closing tunneld tasks...") + await self.tunnel_service.stop_watcher() + await self.icloud_service.stop() + await self.simulation_service.empty_queue() + await self._tunneld_core.close() + await self.context.sio.shutdown() + + def _register_socketio_events(self) -> None: + SocketIOEventHandlers( + context=self.context, + tunneld_core=self._tunneld_core, + get_status=self.simulation_service.get_status, + get_simulation_status=self.simulation_service.get_simulation_status, + device_shutdown=self.tunnel_service.device_shutdown, + device_reboot=self.tunnel_service.device_reboot, + advance_simulation_queue=self.simulation_service.advance_queue, + toggle_test_mode=self.simulation_service.toggle_test_mode, + add_location_to_simulation_queue=self.simulation_service.add_location_to_queue, + delete_location_from_simulation_queue=self.simulation_service.delete_location_from_queue, + clear_future_items=self.simulation_service.clear_future_items, + reset_queue=self.simulation_service.reset_queue, + pause_simulation_queue=self.simulation_service.pause_queue, + resume_simulation_queue=self.simulation_service.resume_queue, + end_simulation_queue=self.simulation_service.end_queue, + start_simulation_queue=self.simulation_service.start_queue, + restart_simulation_queue=self.simulation_service.restart_queue, + toggle_gps_noise=self.simulation_service.toggle_gps_noise, + update_queue_times=self.simulation_service.update_queue_times, + start_icloud_monitor=self.icloud_service.start, + end_icloud_monitor=self.icloud_service.stop, + is_icloud_monitor_running=self.icloud_service.is_running, + refresh_icloud_location=self.icloud_service.refresh_location, + start_tunnel_watcher=self.tunnel_service.start_watcher, + end_tunnel_watcher=self.tunnel_service.stop_watcher, + get_reverse_geocode=self.simulation_service.get_reverse_geocode, + set_favorite=self.simulation_service.set_favorite, + clear_favorite=self.simulation_service.clear_favorite, + get_favorites=self.simulation_service.get_favorites, + add_category=self.simulation_service.add_category, + delete_category=self.simulation_service.delete_category, + get_categories=self.simulation_service.get_categories, + ).register() + + def _mount_vue_app(self) -> None: + vue_dist = os.getenv("VUE_DIST") + if not vue_dist: + logger.warning("VUE_DIST is not set; Vue static app will not be mounted") + return + + vue_dist_path = Path(vue_dist) + assets_path = vue_dist_path / "assets" + index_path = vue_dist_path / "index.html" + if assets_path.exists(): + self._vue_app.mount( + "/assets", + StaticFiles(directory=str(assets_path), html=True), + name="vue", + ) + else: + logger.warning("Vue assets directory not found: %s", assets_path) + + if index_path.exists(): + @self._vue_app.get("/{full_path:path}") + async def serve_vue_app(full_path: str): + return FileResponse(index_path) + else: + logger.warning("Vue index.html not found: %s", index_path) def _run_app(self) -> None: uvicorn.run( self._asgi_app, host=self.host, port=self.port, loop="asyncio", workers=1 ) -class LocationSimulationQueue(LocationSimulation): - def __init__(self, dvt, context: LocationSimulationState): - if dvt is None: - LocationSimulationBase.__init__(self) - else: - super().__init__(dvt) - self.context = context - self._dt_simulate_location = None - self._prefer_dt_simulate_location = False - self._noise_task: Optional[asyncio.Task] = None - self._noise_loc_id: Optional[str] = None - async def _get_dt_simulate_location(self): - if DtSimulateLocation is None: - raise RuntimeError( - "DtSimulateLocation is not available in this pymobiledevice3 build") - if self._dt_simulate_location is None: - lockdown = getattr(self.provider, "lockdown", None) - if lockdown is None: - raise RuntimeError( - "DVT provider does not expose lockdown provider for fallback simulation") - self._dt_simulate_location = DtSimulateLocation(lockdown) - return self._dt_simulate_location - - async def set(self, latitude: float, longitude: float) -> None: - if self.context.test_mode: - await asyncio.sleep(0.1) - logger.info("Test mode enabled, simulated location set to %s, %s", latitude, longitude) - return - if not self._prefer_dt_simulate_location: - try: - await super().set(latitude, longitude) - return - except Exception: - logger.exception( - "DVT location set failed; switching to DtSimulateLocation fallback" - ) - self._prefer_dt_simulate_location = True - fallback = await self._get_dt_simulate_location() - await fallback.set(latitude, longitude) - - async def clear(self) -> None: - if self.context.test_mode: - logger.info("Test mode enabled, skipping location clear") - return - dvt_clear_error = None - if not self._prefer_dt_simulate_location: - try: - await super().clear() - except Exception as e: - dvt_clear_error = e - logger.warning("DVT location clear failed: %s", e) - if self._prefer_dt_simulate_location: - fallback = await self._get_dt_simulate_location() - await fallback.clear() - elif dvt_clear_error is not None: - raise dvt_clear_error - - @staticmethod - def _add_gps_noise(lat: float, lon: float, std_dev_meters: float = 5.0) -> tuple[float, float]: - """Apply Gaussian jitter in meters and convert to lat/lon deltas.""" - earth_radius = 6378137.0 - lat_sigma_deg = (std_dev_meters / earth_radius) * (180.0 / math.pi) - cos_lat = math.cos(math.radians(lat)) - if abs(cos_lat) < 1e-6: - cos_lat = 1e-6 - lon_sigma_deg = (std_dev_meters / (earth_radius * - cos_lat)) * (180.0 / math.pi) - noised_lat = lat + random.gauss(0.0, lat_sigma_deg) - noised_lon = lon + random.gauss(0.0, lon_sigma_deg) - return noised_lat, noised_lon - - async def _stop_noise_task(self) -> None: - if self._noise_task is not None: - self._noise_task.cancel() - with suppress(asyncio.CancelledError): - await self._noise_task - self._noise_task = None - self._noise_loc_id = None - - async def _noise_loop(self, loc_id: str, base_latitude: float, base_longitude: float) -> None: - while True: - await asyncio.sleep(random.randint(45, 180)) - if not self.context.simulation_active: - break - if self.context.simulation_queue_state == "SHUTDOWN": - break - if not self.context.simulation_noise: - continue - if self.context.get_current_loc_id() != loc_id: - break - noised_latitude, noised_longitude = self._add_gps_noise( - base_latitude, base_longitude - ) - await self.set(noised_latitude, noised_longitude) - logger.info( - "Applied simulation noise to active location loc_id=%s sent=%s,%s base=%s,%s", - loc_id, - noised_latitude, - noised_longitude, - base_latitude, - base_longitude, - ) - - def _start_noise_task(self, loc_id: str, base_latitude: float, base_longitude: float) -> None: - self._noise_loc_id = loc_id - self._noise_task = asyncio.create_task( - self._noise_loop(loc_id, base_latitude, base_longitude), - name=f"simulation-noise-{loc_id}", - ) - - async def _update_queue_data(self): - data = { - "active": self.context.simulation_active, - "data": self.context.simulation_queue_data, - "order": self.context.simulation_queue_order, - "deleted_items": self.context.simulation_queue_deleted_items, - "state": self.context.simulation_queue_state, - "worker_task": self.context.simulation_task.get_name() if self.context.simulation_task else None, - "gps_noise": self.context.simulation_noise, - "test_mode": self.context.test_mode, - } - await self.context.sio.emit("queue_data_update", data, namespace="/") - - async def _update_location_item(self, loc_id: str) -> None: - await self.context.sio.emit( - "location_item_update", - { - "loc_id": loc_id, - "data": self.context.simulation_queue_data[loc_id] - }, - namespace="/", - ) - - async def _update_current_location(self, active_loc_id, new_latitude, new_longitude) -> None: - await self.context.sio.emit( - "simulation_status", - { - "status": self.context.simulation_active, - "loc_id": active_loc_id, - "latitude": new_latitude, - "longitude": new_longitude - }, - namespace="/", - ) - - async def _enqueue_next_queue_item(self) -> Optional[str]: - if self.context.simulation_queue_state == "SHUTDOWN": - return None - for item_id in self.context.simulation_queue_order: - if item_id in self.context.simulation_queue_pending_ids: - continue - item = self.context.simulation_queue_data.get(item_id) - if not isinstance(item, dict): - continue - if item.get("status") != "queued": - continue - self.context.simulation_queue_pending_ids.add(item_id) - await self.context.simulation_queue.put(item_id) - logger.info("Worker scheduled queue item %s", item_id) - return item_id - return None - - async def play_queue( - self, disable_sleep: bool = False, timing_randomness_range: int = 0 - ) -> None: - try: - while True: - if self.context.simulation_queue_state == "PAUSED": - await asyncio.sleep(0.1) - continue - if self.context.simulation_queue_state == "SHUTDOWN": - break - await self._enqueue_next_queue_item() - loc_id = await self.context.simulation_queue.get() - if loc_id is None: - self.context.simulation_queue.task_done() - break - location_item = self.context.simulation_queue_data.get(loc_id) - if location_item is None: - logger.warning( - "Simulation queue item missing for loc_id=%s; skipping stale entry", - loc_id, - ) - self.context.simulation_queue_pending_ids.discard(loc_id) - self.context.simulation_queue.task_done() - await self._enqueue_next_queue_item() - continue - new_status = location_item.get("status") - if new_status == "deleted": - self.context.simulation_queue_pending_ids.discard(loc_id) - self.context.simulation_queue.task_done() - await self._enqueue_next_queue_item() - continue - new_latitude = location_item.get("latitude") - new_longitude = location_item.get("longitude") - new_delay = location_item.get("delay") - new_delay = 0 if new_delay is None else new_delay - new_start = location_item.get("start") - current_loc_id = self.context.get_current_loc_id() - current_location_item = self.context.simulation_queue_data.get( - current_loc_id) if current_loc_id else None - current_latitude = ( - current_location_item.get("latitude") - if isinstance(current_location_item, dict) - else None - ) - current_longitude = ( - current_location_item.get("longitude") - if isinstance(current_location_item, dict) - else None - ) - current_start = ( - current_location_item.get("start") - if isinstance(current_location_item, dict) - else None - ) - while self.context.simulation_queue_data[loc_id]["delay"] > 0: - if self.context.simulation_queue_state == "SHUTDOWN": - break - while self.context.simulation_queue_state == "PAUSED": - await asyncio.sleep(0.1) - if self.context.simulation_queue_state == "SHUTDOWN": - break - if self.context.simulation_queue_state == "SHUTDOWN": - break - self.context.next_move = location_item.get("delay") - 1 - self.context.simulation_queue_data[loc_id]["delay"] -= 1 - await self._update_location_item(loc_id) - await asyncio.sleep(1) - if self.context.simulation_queue_state == "SHUTDOWN": - self.context.simulation_queue_pending_ids.discard(loc_id) - self.context.simulation_queue.task_done() - break - self.context.simulation_queue_data[loc_id]["start"] = datetime.now( - timezone.utc).isoformat() - if current_loc_id is not None: - self.context.simulation_queue_data[current_loc_id]["status"] = "done" - self.context.simulation_queue_data[current_loc_id]["end"] = datetime.now( - timezone.utc).isoformat() - self.context.simulation_queue_data[current_loc_id]["delay"] = new_delay - await self._update_location_item(current_loc_id) - await self._update_queue_data() - await self._stop_noise_task() - await self.set(new_latitude, new_longitude) - self.context.simulation_queue_data[loc_id]["status"] = "set" - await self._update_location_item(loc_id) - self.context.set_current_loc_id(loc_id) - active_loc_id = self.context.get_current_loc_id() - await self._update_current_location(active_loc_id, new_latitude, new_longitude) - if self.context.simulation_noise: - self._start_noise_task( - loc_id, new_latitude, new_longitude) - logger.info( - "Set simulated location to %s, %s after %ss delay", - new_latitude, - new_longitude, - new_delay, - ) - await self._update_queue_data() - self.context.simulation_queue_pending_ids.discard(loc_id) - self.context.simulation_queue.task_done() - await self._enqueue_next_queue_item() - finally: - await self._stop_noise_task() +__all__ = ["LocationSimulationState", "TunneldRunnerSio", "env_flag"] diff --git a/src/pymd3_vue_location_sim/simulation_service.py b/src/pymd3_vue_location_sim/simulation_service.py new file mode 100644 index 0000000..f2c3a5b --- /dev/null +++ b/src/pymd3_vue_location_sim/simulation_service.py @@ -0,0 +1,1030 @@ +import asyncio +import math +import random +import traceback +import uuid +from contextlib import suppress +from datetime import datetime, timedelta, timezone +from typing import Optional + +from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider +from pymobiledevice3.services.dvt.instruments.location_simulation import ( + LocationSimulation, +) +from pymobiledevice3.services.dvt.instruments.location_simulation_base import ( + LocationSimulationBase, +) + +try: + from pymobiledevice3.services.simulate_location import DtSimulateLocation +except Exception: + DtSimulateLocation = None + +from .json_formatter import logger +from .models import Category, Favorite + +TUNNEL_ACQUIRE_TIMEOUT_SECONDS = 15 +DVT_CONNECT_TIMEOUT_SECONDS = 20 + + +class SimulationService: + def __init__(self, context, tunnel_service, icloud_service): + self.context = context + self.tunnel_service = tunnel_service + self.icloud_service = icloud_service + + @staticmethod + def parse_delay_seconds(raw_delay) -> int: + if raw_delay is None: + return 0 + if isinstance(raw_delay, bool): + raise ValueError("delay must be a non-negative number") + parsed = float(raw_delay) + if math.isnan(parsed) or math.isinf(parsed) or parsed < 0: + raise ValueError("delay must be a non-negative finite number") + return int(parsed) + + async def start_queue(self): + if ( + self.context.simulation_task is None + or self.context.simulation_task.done() + ): + await self.icloud_service.start() + self.context.simulation_queue_state = "RUNNING" + self.context.simulation_task = asyncio.create_task( + self.start_queue_worker(), + name="location-simulation-worker", + ) + data = { + "command_status": "OK", + "command_class": "simulation_control", + "command": "start", + "message": "Simulation started", + "data": { + "simulation": self.get_simulation_status(), + }, + } + else: + data = { + "command_status": "ERROR", + "command_class": "simulation_control", + "command": "start", + "message": "Simulation already running", + "data": { + "simulation": self.get_simulation_status(), + }, + } + return data + + async def start_queue_worker(self): + """Start Simulation Queue Worker.""" + logger.info("Starting location simulation worker...") + self.context.simulation_queue_state = "RUNNING" + try: + if self.context.test_mode: + logger.info("Simulation worker: test mode enabled") + locate_simulation = LocationSimulationQueue(None, self.context) + await locate_simulation.play_queue() + return + + if self.context.udid is None: + active_udids = sorted( + { + t.udid + for t in self.tunnel_service.tunneld_core.tunnel_tasks.values() + if t.udid is not None and t.tunnel is not None + } + ) + if len(active_udids) == 1: + self.context.udid = active_udids[0] + logger.info( + "Simulation worker: auto-selected udid=%s from active tunnel", + self.context.udid, + ) + elif len(active_udids) == 0: + logger.error( + "Simulation worker: no active tunnel with udid available" + ) + await self.context.sio.emit( + "appError", + { + "type": "simulation_no_tunnel", + "message": "No active tunnel found. Start tunnel first.", + }, + namespace="/", + ) + return + else: + logger.error( + "Simulation worker: multiple active tunnels; explicit udid required: %s", + active_udids, + ) + await self.context.sio.emit( + "appError", + { + "type": "simulation_udid_required", + "message": "Multiple active tunnels found; provide udid in start_simulate_location.", + "udids": active_udids, + }, + namespace="/", + ) + return + + logger.info( + "Simulation worker: acquiring tunnel (udid=%s)", self.context.udid + ) + tun = await self.tunnel_service.get_tun(self.context.udid) + logger.info( + "Simulation worker: tunnel acquired, connecting DVT provider") + dvt_provider = DvtProvider(tun) + dvt = await asyncio.wait_for( + dvt_provider.__aenter__(), timeout=DVT_CONNECT_TIMEOUT_SECONDS + ) + logger.info( + "Simulation worker: DVT provider connected, starting queue playback" + ) + try: + async with LocationSimulationQueue(dvt, self.context) as locate_simulation: + await locate_simulation.play_queue() + finally: + logger.info("Simulation worker: closing DVT provider") + await dvt_provider.__aexit__(None, None, None) + logger.info("Simulation worker: DVT provider closed") + except TimeoutError: + logger.error( + "Simulation worker timeout. tunnel_timeout=%ss dvt_timeout=%ss udid=%s", + TUNNEL_ACQUIRE_TIMEOUT_SECONDS, + DVT_CONNECT_TIMEOUT_SECONDS, + self.context.udid, + ) + await self.context.sio.emit( + "appError", + { + "type": "simulation_timeout", + "udid": self.context.udid, + "tunnel_timeout_seconds": TUNNEL_ACQUIRE_TIMEOUT_SECONDS, + "dvt_timeout_seconds": DVT_CONNECT_TIMEOUT_SECONDS, + }, + namespace="/", + ) + except Exception: + logger.exception("Simulation worker crashed") + await self.context.sio.emit( + "appError", + { + "type": "simulation_crash", + "udid": self.context.udid, + "error": traceback.format_exc(), + }, + namespace="/", + ) + finally: + if self.context.simulation_queue_state != "SHUTDOWN": + self.context.simulation_queue_state = "STOPPED" + self.context.simulation_task = None + await self.icloud_service.stop() + + async def restart_queue(self): + await self.end_queue() + return await self.start_queue() + + async def add_location_to_queue(self, data): + loc_id = str(uuid.uuid4()) + latitude = ( + data.get("latitude") + if isinstance(data, dict) + else getattr(data, "latitude", None) + ) + longitude = ( + data.get("longitude") + if isinstance(data, dict) + else getattr(data, "longitude", None) + ) + delay = ( + data.get("delay", 0) + if isinstance(data, dict) + else getattr(data, "delay", 0) + ) + address = ( + data.get("address") + if isinstance(data, dict) + else getattr(data, "address", None) + ) + try: + delay = self.parse_delay_seconds(delay) + except ValueError as e: + return { + "command_status": "ERROR", + "command_class": "simulation_control", + "command": "add", + "message": str(e), + } + if latitude is not None and longitude is not None: + logger.info( + "Adding location %s (%s, %s) with %s delay to the queue", + loc_id, + latitude, + longitude, + delay, + ) + accrued_delay = 0 + current_loc_id = self.context.get_current_loc_id() + if self.context.simulation_queue_data and len(self.context.simulation_queue_order) > 1: + current_index = self.get_item_index( + current_loc_id) if current_loc_id else 0 + remaining_items = self.context.simulation_queue_order[current_index + 1:] + accrued_delay = sum( + self.parse_delay_seconds( + self.context.simulation_queue_data[item_id]["delay"] + ) + for item_id in remaining_items + if item_id in self.context.simulation_queue_data + ) + accrued_delay = accrued_delay + self.parse_delay_seconds( + self.context.next_move + ) + + now_time = datetime.now(timezone.utc) + new_time = ( + now_time + timedelta(seconds=accrued_delay) + + timedelta(seconds=delay) + ) + location_item = { + "loc_id": loc_id, + "latitude": latitude, + "longitude": longitude, + "delay": delay, + "status": "queued", + "start": new_time.isoformat(), + "address": address, + } + resp = { + "command_status": "OK", + "command_class": "simulation_control", + "command": "add", + "message": f"Location {loc_id} added to the queue", + "data": { + "simulation": self.get_simulation_status(), + "location_item": location_item, + }, + } + self.add_item(loc_id, location_item) + await self.enqueue_next_simulation_item() + logger.info("Location %s added to the queue", loc_id) + else: + logger.error("Invalid location data: %s", data) + resp = { + "command_status": "ERROR", + "command_class": "simulation_control", + "command": "add", + "message": "Invalid location data", + "data": data, + } + return resp + + async def delete_location_from_queue(self, data): + loc_id = ( + data.get("loc_id") + if isinstance(data, dict) + else getattr(data, "loc_id", None) + ) + if loc_id is not None: + if loc_id not in self.context.simulation_queue_order: + return { + "command_status": "ERROR", + "command_class": "simulation_control", + "command": "delete", + "message": f"Location {loc_id} not found in the queue", + "data": { + "simulation": self.get_simulation_status(), + "location_item": self.context.simulation_queue_data.get(loc_id), + }, + } + logger.info("Deleting location %s from the queue", loc_id) + await self.delete_item(loc_id) + return { + "command_status": "OK", + "command_class": "simulation_control", + "command": "delete", + "message": f"Location {loc_id} deleted from the queue", + "data": { + "simulation": self.get_simulation_status(), + "location_item": self.context.simulation_queue_data[loc_id], + }, + } + return { + "command_status": "ERROR", + "command_class": "simulation_control", + "command": "delete", + "message": "Invalid location data", + "data": { + "simulation:": self.get_simulation_status(), + }, + } + + async def pause_queue(self): + self.context.simulation_queue_state = "PAUSED" + + async def resume_queue(self): + self.context.simulation_queue_state = "RUNNING" + await self.update_queue_times() + + async def advance_queue(self): + current_loc_id = self.context.get_current_loc_id() + self.context.simulation_queue_data[current_loc_id]["delay"] = 0 + await self.update_queue_times() + + async def update_queue_times(self): + current_loc_id = self.context.get_current_loc_id() + if ( + current_loc_id + and current_loc_id in self.context.simulation_queue_data + ): + current_index = self.get_item_index( + current_loc_id) if current_loc_id else 0 + remaining_items = self.context.simulation_queue_order[current_index + 1:] + new_delay = 0 + now_time = datetime.now(timezone.utc) + for item in remaining_items: + item_delay = self.parse_delay_seconds( + self.context.simulation_queue_data[item].get("delay") + ) or 0 + new_delay += item_delay + new_time = (now_time + timedelta(seconds=new_delay)).isoformat() + logger.info("Updating queue time for %s to %s", item, new_time) + self.context.simulation_queue_data[item]["start"] = new_time + await self.update_queue_data() + + + async def update_queue_data(self): + data = { + "simulation": { + "data": self.context.simulation_queue_data, + "order": self.context.simulation_queue_order, + "deleted_items": self.context.simulation_queue_deleted_items, + }, + } + await self.context.sio.emit("status", data, namespace="/") + + async def empty_queue(self): + logger.info("Clearing location simulation queue...") + q = self.context.simulation_queue + while not q.empty(): + try: + item = q.get_nowait() + if isinstance(item, str): + self.context.simulation_queue_pending_ids.discard(item) + q.task_done() + logger.info("Discarding item from queue: %s", item) + except asyncio.QueueEmpty: + break + self.context.simulation_queue_pending_ids.clear() + self.reset_queue() + + def add_item(self, item_id, payload): + self.context.simulation_queue_data[item_id] = payload + self.context.simulation_queue_order.append(item_id) + + def remove_item(self, item_id): + if item_id in self.context.simulation_queue_order: + self.context.simulation_queue_order.remove(item_id) + del self.context.simulation_queue_data[item_id] + + def reset_queue(self): + self.context.simulation_queue_data = {} + self.context.simulation_queue_order = [] + self.context.simulation_queue_deleted_items = [] + self.context.set_current_loc_id(None) + + def remove_future_items(self): + current_loc_id = self.context.get_current_loc_id() + if ( + current_loc_id + and self.context.simulation_active + and current_loc_id in self.context.simulation_queue_data + ): + self.context.simulation_queue_order = [current_loc_id] + self.context.simulation_queue_data = { + current_loc_id: self.context.simulation_queue_data[current_loc_id] + } + else: + self.context.simulation_queue_data = {} + self.context.simulation_queue_order = [] + self.context.simulation_queue_deleted_items = [] + self.context.set_current_loc_id(None) + + async def delete_item(self, item_id): + if item_id in self.context.simulation_queue_order: + self.context.simulation_queue_data[item_id]["status"] = "deleted" + self.context.simulation_queue_order.remove(item_id) + self.context.simulation_queue_deleted_items.append(item_id) + await self.update_queue_times() + + def clear_future_items(self): + current_loc_id = self.context.get_current_loc_id() + current_index = self.get_item_index( + current_loc_id) if current_loc_id else 0 + for item in self.context.simulation_queue_order[current_index + 1:]: + self.context.simulation_queue_data[item]["status"] = "deleted" + self.context.simulation_queue_order.remove(item) + self.context.simulation_queue_deleted_items.append(item) + + def get_item(self, item_id): + return self.context.simulation_queue_data[item_id] + + def update_item(self, item_id, **updates): + if item_id in self.context.simulation_queue_data: + self.context.simulation_queue_data[item_id].update(updates) + + def get_item_index(self, item_id): + return self.context.simulation_queue_order.index(item_id) + + def get_item_id_by_index(self, index): + return self.context.simulation_queue_order[index] + + def get_items_in_order(self): + return [ + self.context.simulation_queue_data[i] + for i in self.context.simulation_queue_order + if self.context.simulation_queue_data[i].get("status") != "deleted" + ] + + async def enqueue_next_simulation_item(self) -> Optional[str]: + if self.context.simulation_queue_state == "SHUTDOWN": + return None + stale_pending_ids = { + pending_id + for pending_id in self.context.simulation_queue_pending_ids + if not isinstance(self.context.simulation_queue_data.get(pending_id), dict) + or self.context.simulation_queue_data[pending_id].get("status") != "queued" + } + if stale_pending_ids: + for stale_id in stale_pending_ids: + self.context.simulation_queue_pending_ids.discard(stale_id) + logger.info( + "Cleared stale pending queue ids before enqueue: %s", + sorted(stale_pending_ids), + ) + if self.context.simulation_queue_pending_ids: + return None + for item_id in self.context.simulation_queue_order: + if item_id in self.context.simulation_queue_pending_ids: + continue + item = self.context.simulation_queue_data.get(item_id) + if not isinstance(item, dict): + continue + if item.get("status") != "queued": + continue + self.context.simulation_queue_pending_ids.add(item_id) + await self.context.simulation_queue.put(item_id) + logger.info("Scheduled queue item %s", item_id) + return item_id + return None + + async def end_queue(self): + logger.info("End location simulation request") + end_task = asyncio.create_task( + self.end_simulation_worker(), name="end-simulation-worker" + ) + return await end_task + + async def end_simulation_worker(self) -> bool: + logger.info("End location simulation request") + try: + q = self.context.simulation_queue + self.context.simulation_queue_state = "SHUTDOWN" + while not q.empty(): + try: + item = q.get_nowait() + if isinstance(item, str): + self.context.simulation_queue_pending_ids.discard(item) + q.task_done() + logger.info("Discarding item from queue: %s", item) + except asyncio.QueueEmpty: + break + + await q.put(None) + self.reset_queue() + + if ( + self.context.simulation_task is not None + and not self.context.simulation_task.done() + ): + try: + await asyncio.wait_for(self.context.simulation_task, timeout=5) + except TimeoutError: + logger.warning( + "Simulation worker did not stop in time; canceling task") + self.context.simulation_task.cancel() + with suppress(asyncio.CancelledError): + await self.context.simulation_task + + if not self.context.test_mode and self.context.tunnel is not None: + async with ( + DvtProvider(self.context.tunnel) as dvt, + LocationSimulationQueue(dvt, self.context) as locate_simulation, + ): + await locate_simulation.clear() + self.context.simulation_task = None + self.context.next_move = None + self.context.set_current_loc_id(None) + self.context.simulation_queue_state = "STOPPED" + self.context.simulation_queue = asyncio.Queue() + self.context.simulation_queue_pending_ids = set() + await self.icloud_service.stop() + return True + except Exception as e: + logger.error("Error ending simulation queue: %s", e) + return False + + def toggle_test_mode(self) -> dict: + self.context.test_mode = not self.context.test_mode + return {"test_mode": self.context.test_mode} + + def toggle_gps_noise(self) -> dict: + self.context.simulation_noise = not self.context.simulation_noise + return {"simulation_noise": self.context.simulation_noise} + + def get_simulation_status(self) -> dict: + return { + "current_location": { + "loc_id": self.context.get_current_loc_id(), + }, + "active": self.context.simulation_active, + "data": self.context.simulation_queue_data, + "order": self.context.simulation_queue_order, + "deleted_items": self.context.simulation_queue_deleted_items, + "state": self.context.simulation_queue_state, + "gps_noise": self.context.simulation_noise, + "worker_task": self.context.simulation_task.get_name() + if self.context.simulation_task + else None, + "test_mode": self.context.test_mode, + } + + def get_uptime(self) -> float: + with open('/proc/uptime', 'r') as f: + # The first number is total uptime seconds + uptime_seconds = float(f.readline().split()[0]) + return uptime_seconds + + def get_status(self) -> dict: + return { + "socket": { + "connected_clients": list(self.context.connected_clients) + }, + "simulation": self.get_simulation_status(), + "device": { + "udid": self.context.udid, + "device_name": self.context.device_name, + "uptime": self.get_uptime(), + }, + "icloud": { + "fmf_location": self.context.fmf_location, + "consumer_queue": self.context.fmf_queue.qsize() + if self.context.fmf_queue + else 0, + "consumer_task": self.context.icloud_consumer_task.done() + if self.context.icloud_consumer_task + else False, + "monitor_task": self.context.icloud_monitor_task.done() + if self.context.icloud_monitor_task + else False, + "monitor_enabled": self.context.icloud_monitor_enabled, + "monitor_running": self.icloud_service.is_running(), + }, + "tunneld": { + "tunnel_watcher_running": True + if self.context.tunnel_watcher_task + and not self.context.tunnel_watcher_task.done() + else False, + "tunnel": self.context.tunnel.service.address[0] + if self.context.tunnel + else None, + }, + } + + async def get_reverse_geocode(self, data): + latitude = float( + data.get("latitude", 999) + if isinstance(data, dict) + else getattr(data, "latitude", 999) + ) + longitude = float( + data.get("longitude", 999) + if isinstance(data, dict) + else getattr(data, "longitude", 999) + ) + if latitude != 999 and longitude != 999: + return await self.context.reverse_geocode.get_address(latitude, longitude) + return None + + async def get_favorites(self): + favorites = self.context.reverse_geocode.get_favorites() + return favorites + + async def clear_favorite(self, data): + favorite_id = data.get("id", None) if isinstance( + data, dict) else getattr(data, "id", None) + if favorite_id is not None: + clear_status = self.context.reverse_geocode.clear_favorite( + favorite_id) + if clear_status: + return "OK", "Favorite cleared" + return "ERROR", "Failed to clear favorite" + return "ERROR", "Invalid latitude and longitude" + + async def set_favorite(self, data): + fav = ( + data.get("favorite") + if isinstance(data, dict) + else getattr(data, "favorite", None) + ) + if fav is not None: + if isinstance(fav, dict): + name = fav.get("name", '') + latitude = float(fav.get("latitude", 999)) + longitude = float(fav.get("longitude", 999)) + icon = fav.get("icon", '') + category = fav.get("category", '') + address = fav.get("address", {}) + radius = float(fav.get("radius", 0)) + else: + name = getattr(fav, "name", '') + latitude = float(getattr(fav, "latitude", 999)) + longitude = float(getattr(fav, "longitude", 999)) + icon = getattr(fav, "icon", '') + category = getattr(fav, "category", '') + address = getattr(fav, "address", {}) + radius = float(getattr(fav, "radius", 0)) + + favorite: Favorite = { + "name": name, + "latitude": latitude, + "longitude": longitude, + "icon": icon, + "category": category, + "address": address, + "radius": radius + } + if latitude != 999 and longitude != 999 and favorite: + category_id = ( + fav.get("category_id", fav.get("category")) + if isinstance(fav, dict) + else getattr(fav, "category_id", getattr(fav, "category", None)) + ) + set_status = self.context.reverse_geocode.add_favorite( + latitude, longitude, category_id, favorite + ) + if set_status: + return "OK", "Favorite added" + return "ERROR", "Failed to add favorite" + return "ERROR", "Invalid latitude and longitude" + return "ERROR", "Invalid favorite data" + + async def get_categories(self): + return self.context.reverse_geocode.get_categories() + + async def delete_category(self, data): + category_id = ( + data.get("id", None) + if isinstance(data, dict) + else getattr(data, "id", None) + ) + if category_id is not None: + delete_status = self.context.reverse_geocode.delete_category( + category_id) + if delete_status: + return "OK", "category cleared" + return "ERROR", "Failed to clear category" + return "ERROR", "category_id not found" + + async def add_category(self, data): + cat = ( + data.get("category") + if isinstance(data, dict) + else getattr(data, "category", None) + ) + if cat is None: + return "ERROR", "Invalid category data" + + if isinstance(cat, dict): + name = cat.get("name", "") + icon = cat.get("icon", "") + color = cat.get("color", "") + else: + name = getattr(cat, "name", "") + icon = getattr(cat, "icon", "") + color = getattr(cat, "color", "") + + category: Category = { + "name": name, + "icon": icon, + "color": color, + } + if not category["name"]: + return "ERROR", "Invalid category data" + + add_status = self.context.reverse_geocode.add_category( + name, icon, color) + if add_status: + return "OK", "category added" + return "ERROR", "Failed to add category" + + +class LocationSimulationQueue(LocationSimulation): + def __init__(self, dvt, context): + if dvt is None: + LocationSimulationBase.__init__(self) + else: + super().__init__(dvt) + self.context = context + self._dt_simulate_location = None + self._prefer_dt_simulate_location = False + self._noise_task: Optional[asyncio.Task] = None + self._noise_loc_id: Optional[str] = None + + async def _get_dt_simulate_location(self): + if DtSimulateLocation is None: + raise RuntimeError( + "DtSimulateLocation is not available in this pymobiledevice3 build" + ) + if self._dt_simulate_location is None: + lockdown = getattr(self.provider, "lockdown", None) + if lockdown is None: + raise RuntimeError( + "DVT provider does not expose lockdown provider for fallback simulation" + ) + self._dt_simulate_location = DtSimulateLocation(lockdown) + return self._dt_simulate_location + + async def set(self, latitude: float, longitude: float) -> None: + if self.context.test_mode: + await asyncio.sleep(0.1) + logger.info( + "Test mode enabled, simulated location set to %s, %s", + latitude, + longitude, + ) + return + if not self._prefer_dt_simulate_location: + try: + await super().set(latitude, longitude) + return + except Exception: + logger.exception( + "DVT location set failed; switching to DtSimulateLocation fallback" + ) + self._prefer_dt_simulate_location = True + fallback = await self._get_dt_simulate_location() + await fallback.set(latitude, longitude) + + async def clear(self) -> None: + if self.context.test_mode: + logger.info("Test mode enabled, skipping location clear") + return + dvt_clear_error = None + if not self._prefer_dt_simulate_location: + try: + await super().clear() + except Exception as e: + dvt_clear_error = e + logger.warning("DVT location clear failed: %s", e) + if self._prefer_dt_simulate_location: + fallback = await self._get_dt_simulate_location() + await fallback.clear() + elif dvt_clear_error is not None: + raise dvt_clear_error + + @staticmethod + def _add_gps_noise( + lat: float, lon: float, std_dev_meters: float = 5.0 + ) -> tuple[float, float]: + """Apply Gaussian jitter in meters and convert to lat/lon deltas.""" + earth_radius = 6378137.0 + lat_sigma_deg = (std_dev_meters / earth_radius) * (180.0 / math.pi) + cos_lat = math.cos(math.radians(lat)) + if abs(cos_lat) < 1e-6: + cos_lat = 1e-6 + lon_sigma_deg = (std_dev_meters / (earth_radius * cos_lat)) * ( + 180.0 / math.pi + ) + noised_lat = lat + random.gauss(0.0, lat_sigma_deg) + noised_lon = lon + random.gauss(0.0, lon_sigma_deg) + return noised_lat, noised_lon + + async def _stop_noise_task(self) -> None: + if self._noise_task is not None: + self._noise_task.cancel() + with suppress(asyncio.CancelledError): + await self._noise_task + self._noise_task = None + self._noise_loc_id = None + + async def _noise_loop( + self, loc_id: str, base_latitude: float, base_longitude: float + ) -> None: + while True: + await asyncio.sleep(random.randint(45, 180)) + if not self.context.simulation_active: + break + if self.context.simulation_queue_state == "SHUTDOWN": + break + if not self.context.simulation_noise: + continue + if self.context.get_current_loc_id() != loc_id: + break + noised_latitude, noised_longitude = self._add_gps_noise( + base_latitude, base_longitude + ) + await self.set(noised_latitude, noised_longitude) + logger.info( + "Applied simulation noise to active location loc_id=%s sent=%s,%s base=%s,%s", + loc_id, + noised_latitude, + noised_longitude, + base_latitude, + base_longitude, + ) + + def _start_noise_task( + self, loc_id: str, base_latitude: float, base_longitude: float + ) -> None: + self._noise_loc_id = loc_id + self._noise_task = asyncio.create_task( + self._noise_loop(loc_id, base_latitude, base_longitude), + name=f"simulation-noise-{loc_id}", + ) + + async def _update_queue_data(self): + data = { + "simulation": { + "data": self.context.simulation_queue_data, + "order": self.context.simulation_queue_order, + "deleted_items": self.context.simulation_queue_deleted_items, + }, + } + await self.context.sio.emit("status", data, namespace="/") + + +# async def _update_queue_data(self): +# data = { +# "active": self.context.simulation_active, +# "data": self.context.simulation_queue_data, +# "order": self.context.simulation_queue_order, +# "deleted_items": self.context.simulation_queue_deleted_items, +# "state": self.context.simulation_queue_state, +# "worker_task": self.context.simulation_task.get_name() +# if self.context.simulation_task +# else None, +# "gps_noise": self.context.simulation_noise, +# "test_mode": self.context.test_mode, +# } +# await self.context.sio.emit("queue_data_update", data, namespace="/") + + async def _update_location_item(self, loc_id: str) -> None: + await self.context.sio.emit( + "status", + { + "simulation": { + "data": { + loc_id: self.context.simulation_queue_data[loc_id], + }, + }, + }, + namespace="/", + ) + +# async def _update_location_item(self, loc_id: str) -> None: +# await self.context.sio.emit( +# "location_item_update", +# { +# "loc_id": loc_id, +# "data": self.context.simulation_queue_data[loc_id], +# }, +# namespace="/", +# ) + + async def _update_current_location( + self, active_loc_id, new_latitude, new_longitude + ) -> None: + await self.context.sio.emit( + "status", + { + "simulation": { + "active": self.context.simulation_active, + "current_location": { + "loc_id": active_loc_id, + "latitude": new_latitude, + "longitude": new_longitude + }, + }, + }, + namespace="/", + ) + + async def _enqueue_next_queue_item(self) -> Optional[str]: + if self.context.simulation_queue_state == "SHUTDOWN": + return None + for item_id in self.context.simulation_queue_order: + if item_id in self.context.simulation_queue_pending_ids: + continue + item = self.context.simulation_queue_data.get(item_id) + if not isinstance(item, dict): + continue + if item.get("status") != "queued": + continue + self.context.simulation_queue_pending_ids.add(item_id) + await self.context.simulation_queue.put(item_id) + logger.info("Worker scheduled queue item %s", item_id) + return item_id + return None + + async def play_queue( + self, disable_sleep: bool = False, timing_randomness_range: int = 0 + ) -> None: + loc_id = None + try: + while True: + if self.context.simulation_queue_state == "PAUSED": + await asyncio.sleep(0.1) + continue + if self.context.simulation_queue_state == "SHUTDOWN": + break + await self._enqueue_next_queue_item() + loc_id = await self.context.simulation_queue.get() + if loc_id is None: + self.context.simulation_queue.task_done() + break + location_item = self.context.simulation_queue_data.get(loc_id) + if location_item is None: + logger.warning( + "Simulation queue item missing for loc_id=%s; skipping stale entry", + loc_id, + ) + self.context.simulation_queue_pending_ids.discard(loc_id) + self.context.simulation_queue.task_done() + await self._enqueue_next_queue_item() + continue + if location_item.get("status") == "deleted": + self.context.simulation_queue_pending_ids.discard(loc_id) + self.context.simulation_queue.task_done() + await self._enqueue_next_queue_item() + continue + new_latitude = location_item.get("latitude") + new_longitude = location_item.get("longitude") + new_delay = location_item.get("delay") + new_delay = 0 if new_delay is None else new_delay + current_loc_id = self.context.get_current_loc_id() + while self.context.simulation_queue_data[loc_id]["delay"] > 0: + if self.context.simulation_queue_state == "SHUTDOWN": + break + while self.context.simulation_queue_state == "PAUSED": + await asyncio.sleep(0.1) + if self.context.simulation_queue_state == "SHUTDOWN": + break + if self.context.simulation_queue_state == "SHUTDOWN": + break + self.context.next_move = location_item.get("delay") - 1 + self.context.simulation_queue_data[loc_id]["delay"] -= 1 + await self._update_location_item(loc_id) + await asyncio.sleep(1) + if self.context.simulation_queue_state == "SHUTDOWN": + self.context.simulation_queue_pending_ids.discard(loc_id) + self.context.simulation_queue.task_done() + break + self.context.simulation_queue_data[loc_id]["start"] = datetime.now( + timezone.utc + ).isoformat() + if current_loc_id is not None: + self.context.simulation_queue_data[current_loc_id]["status"] = "done" + self.context.simulation_queue_data[current_loc_id]["end"] = datetime.now( + timezone.utc + ).isoformat() + self.context.simulation_queue_data[current_loc_id]["delay"] = new_delay + await self._update_location_item(current_loc_id) + await self._update_queue_data() + await self._stop_noise_task() + await self.set(new_latitude, new_longitude) + self.context.simulation_queue_data[loc_id]["status"] = "set" + await self._update_location_item(loc_id) + self.context.set_current_loc_id(loc_id) + active_loc_id = self.context.get_current_loc_id() + await self._update_current_location( + active_loc_id, new_latitude, new_longitude + ) + if self.context.simulation_noise: + self._start_noise_task(loc_id, new_latitude, new_longitude) + logger.info( + "Set simulated location to %s, %s after %ss delay", + new_latitude, + new_longitude, + new_delay, + ) + await self._update_queue_data() + if loc_id is not None: + self.context.simulation_queue_pending_ids.discard(loc_id) + self.context.simulation_queue.task_done() + await self._enqueue_next_queue_item() + finally: + await self._stop_noise_task() diff --git a/src/pymd3_vue_location_sim/sio_events.py b/src/pymd3_vue_location_sim/sio_events.py new file mode 100644 index 0000000..e1b4161 --- /dev/null +++ b/src/pymd3_vue_location_sim/sio_events.py @@ -0,0 +1,782 @@ +import json +import asyncio +import os +import signal +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + +from fastapi.encoders import jsonable_encoder + +from .json_formatter import logger +from .models import LatLng + + +@dataclass(slots=True) +class SocketIOEventHandlers: + context: Any + tunneld_core: Any + get_status: Callable[[], dict] + get_simulation_status: Callable[[], dict] + device_shutdown: Callable[[Any], Awaitable[Any]] + device_reboot: Callable[[Any], Awaitable[Any]] + advance_simulation_queue: Callable[[], Awaitable[Any]] + toggle_test_mode: Callable[[], dict] + add_location_to_simulation_queue: Callable[[Any], Awaitable[dict]] + delete_location_from_simulation_queue: Callable[[Any], Awaitable[dict]] + clear_future_items: Callable[[], Any] + reset_queue: Callable[[], Any] + pause_simulation_queue: Callable[[], Awaitable[Any]] + resume_simulation_queue: Callable[[], Awaitable[Any]] + end_simulation_queue: Callable[[], Awaitable[Any]] + start_simulation_queue: Callable[[], Awaitable[dict]] + restart_simulation_queue: Callable[[], Awaitable[dict]] + toggle_gps_noise: Callable[[], dict] + update_queue_times: Callable[[], Awaitable[Any]] + start_icloud_monitor: Callable[[], Awaitable[Any]] + end_icloud_monitor: Callable[[], Awaitable[Any]] + is_icloud_monitor_running: Callable[[], bool] + refresh_icloud_location: Callable[[], Awaitable[dict]] + start_tunnel_watcher: Callable[[], Awaitable[Any]] + end_tunnel_watcher: Callable[[], Awaitable[Any]] + get_reverse_geocode: Callable[[Any], Awaitable[Any]] + set_favorite: Callable[[Any], Awaitable[tuple[str, str]]] + clear_favorite: Callable[[Any], Awaitable[tuple[str, str]]] + get_favorites: Callable[[], Awaitable[Any]] + get_categories: Callable[[], Awaitable[Any]] + add_category: Callable[[Any], Awaitable[dict]] + delete_category: Callable[[Any], Awaitable[tuple[str, str]]] + + def register(self) -> None: + sio = self.context.sio + + async def emit_test_prompt(test_prompt_data): + prompt_name = ( + test_prompt_data.get("prompt_name") + if isinstance(test_prompt_data, dict) + else getattr(test_prompt_data, "prompt_name", None) + ) + client_id = ( + test_prompt_data.get("client_id") + if isinstance(test_prompt_data, dict) + else getattr(test_prompt_data, "client_id", None) + ) + logger.info( + "Sending test prompt %s sent to client %s", prompt_name, client_id + ) + try: + response = await sio.call( + "test_prompt", + {"prompt_name": prompt_name}, + namespace="/", + to=client_id, + timeout=60, + ) + if response and response.get("confirmed"): + await do_positive_action(prompt_name, client_id) + else: + await do_negative_action(prompt_name, client_id) + except asyncio.TimeoutError: + print(f"Client {client_id} failed to respond in time.") + + async def do_positive_action(prompt_name, client_id): + logger.info( + "Confirmation received from client %s fpr prompt %s", + prompt_name, + client_id, + ) + + async def do_negative_action(prompt_name, client_id): + logger.info( + "Client %s denied confirmation for prompt %s", client_id, prompt_name + ) + + async def sio_send_status(sid): + """Send Current Status""" + if sid in self.context.connected_clients: + logger.info("Sending status to: %s", sid) + await sio.emit( + "status", jsonable_encoder(self.get_status()), namespace="/", to=sid + ) + else: + logger.info("Sending status to all clients, not only: %s", sid) + await sio.emit( + "status", jsonable_encoder(self.get_status()), namespace="/" + ) + + def json_fallback(obj): + if hasattr(obj, "__dict__"): + return obj.__dict__ + if hasattr(obj, "as_dict"): + return obj.as_dict() + if hasattr(obj, "to_dict"): + return obj.to_dict() + return str(obj) + + @sio.event + async def connect(sid, environ): + """Client connection event handler.""" + self.context.connected_clients.add(sid) + logger.info("Client connected: %s", sid) + await sio_send_status(sid) + return "%s connected" % sid + + @sio.event + async def disconnect(sid): + """Client disconnection event handler.""" + self.context.connected_clients.discard(sid) + logger.info("Client disconnected: %s", sid) + + @sio.event + async def send_test_prompt(sid, data): + """Send test prompt""" + prompt_name = ( + data.get("prompt_name") + if isinstance(data, dict) + else getattr(data, "prompt_name", None) + ) + logger.info( + "Received request from %s to send test prompt %s", sid, prompt_name + ) + test_prompt_data = {"prompt_name": prompt_name, "client_id": sid} + sio.start_background_task(emit_test_prompt, test_prompt_data) + return { + "prompt_name": prompt_name, + "command_status": "OK", + "message": "Test prompt requested.", + } + + @sio.event + async def request_update(sid): + status_update = jsonable_encoder(self.get_status()) + logger.info("Update request from %s sending %s", sid, status_update) + return status_update + + @sio.event + async def message(sid, data): + logger.info("Received message from %s: %s", sid, data) + return True, "Message received" + + @sio.event + async def device_control(sid, data): + """Device Control""" + command = ( + data.get("command") + if isinstance(data, dict) + else getattr(data, "command", None) + ) + delay = ( + data.get("delay") + if isinstance(data, dict) + else getattr(data, "delay", None) + ) + if delay is None: + delay = 5 + match command: + case "shutdown": + logger.info( + "Shutdown command received from %s with delay %s", sid, delay + ) + await self.device_shutdown(delay) + return { + "command_status": "OK", + "command_class": "device_control", + "command": "shutdown", + "message": f"Device shutdown initiated with {delay} seconds delay", + } + + case "reboot": + logger.info( + "Reboot command received from %s with delay %s", sid, delay + ) + await self.device_reboot(delay) + return { + "command_status": "OK", + "command_class": "device_control", + "command": "reboot", + "message": f"Device reboot initiated with {delay} seconds delay", + } + + case _: + return { + "command_status": "ERROR", + "command_class": "device_control", + "command": command, + "message": f"Invalid command: {command}", + } + + @sio.event + async def simulation_control(sid, data): + """Simulation Control""" + command = ( + data.get("command") + if isinstance(data, dict) + else getattr(data, "command", None) + ) + logger.info( + "Simulation Control command: %s requested from %s", command, sid + ) + try: + match command: + case "next": + await self.advance_simulation_queue() + return { + "command": command, + "command_class": "simulation_control", + "command_status": "OK", + "message": "Advanced simulation queue", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "test-mode": + self.toggle_test_mode() + return { + "command": command, + "command_class": "simulation_control", + "command_status": "OK", + "message": "test-mode toggled", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "add": + return await self.add_location_to_simulation_queue(data) + + case "delete": + return await self.delete_location_from_simulation_queue(data) + + case "clear": + self.clear_future_items() + return { + "command": command, + "command_class": "simulation_control", + "command_status": "OK", + "message": "Simulation cleared", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "reset": + self.reset_queue() + return { + "command": command, + "command_class": "simulation_control", + "command_status": "OK", + "message": "Simulation queue reset", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "pause": + await self.pause_simulation_queue() + return { + "command": command, + "command_class": "simulation_control", + "command_status": "OK", + "message": "Simulation paused", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "resume": + await self.resume_simulation_queue() + return { + "command": command, + "command_status": "OK", + "command_class": "simulation_control", + "message": "Simulation resumed", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "end": + logger.info("End location simulation request from %s", sid) + cstat = "OK" if await self.end_simulation_queue() else "ERROR" + return { + "command_status": cstat, + "command_class": "simulation_control", + "command": command, + "message": "Simulation ended", + "data": { + "simulation": self.get_simulation_status(), + }, + } + + case "start": + logger.info("Start location simulation request from %s", sid) + return await self.start_simulation_queue() + + case "gps-noise": + before_toggle = self.context.simulation_noise + self.toggle_gps_noise() + return { + "command_status": "OK", + "command_class": "simulation_control", + "command": command, + "message": f"GPS noise toggled from {before_toggle} to {self.context.simulation_noise}", + "data": { + "simulation": self.get_simulation_status(), + }, + } + case "restart": + logger.info("Restart location simulation request from %s", sid) + return await self.restart_simulation_queue() + + case _: + logger.warning( + "Invalid command received from %s: %s", sid, command + ) + return {"command_status": "ERROR", "message": "Invalid command"} + finally: + logger.info( + "Simulation Control command: %s completed, sending status update...", + command, + ) + await sio_send_status(sid) + + @sio.event + async def location_item_update(sid, data): + """Location Item Control""" + loc_id = ( + data.get("loc_id") + if isinstance(data, dict) + else getattr(data, "loc_id", None) + ) + key = ( + data.get("key") + if isinstance(data, dict) + else getattr(data, "key", None) + ) + value = ( + data.get("value") + if isinstance(data, dict) + else getattr(data, "value", None) + ) + error_data = { + "command_status": "ERROR", + "command": "update", + "command_class": "location_item", + "message": "Invalid request, Location Item Unchanged", + "data": self.context.simulation_queue_data[loc_id], + } + + if value is not None: + if loc_id and key: + old_val = self.context.simulation_queue_data[loc_id][key] + self.context.simulation_queue_data[loc_id][key] = value + if key == "delay": + await self.update_queue_times() + logger.info( + "Location Item Update: %s: %s changed from %s to %s", + loc_id, + key, + old_val, + value, + ) + return { + "command_status": "OK", + "command": "update", + "command_class": "location_item", + "message": "Location Item Updated", + "data": { + "simulation": { + "data": { + loc_id: self.context.simulation_queue_data[loc_id], + }, + }, + }, + } + else: + return error_data + else: + return error_data + + @sio.event + async def queue_order_update(sid, data): + logger.info("Queue order update received: %s", data) + new_simulation_queue_order = [] + new_order = ( + data.get("newOrder") + if isinstance(data, dict) + else getattr(data, "newOrder", None) + ) + if new_order: + for item in new_order: + if item in self.context.simulation_queue_data: + new_simulation_queue_order.append(item) + self.context.simulation_queue_order = new_simulation_queue_order + return { + "command_status": "OK", + "command": "queue_order_update", + "command_class": "queue_order", + "message": "Queue Order Updated", + "data": { + "simulation": { + "order" : self.context.simulation_queue_order, + }, + }, + } + return { + "command_status": "ERROR", + "command": "queue_order_update", + "command_class": "queue_order", + "message": "Invalid request, Queue Order Unchanged", + "data": { + "simulation": { + "order": self.context.simulation_queue_order, + }, + }, + } + + @sio.event + async def icloud_monitor_control(sid, data): + command = ( + data.get("command") + if isinstance(data, dict) + else getattr(data, "command", None) + ) + logger.info( + "iCloud Monitor control command: %s requested from %s", command, sid + ) + try: + match command: + case "start": + await self.start_icloud_monitor() + return { + "command": command, + "command_class": "icloud_monitor_control", + "command_status": "OK", + "data": { + "icloud": { + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_icloud_monitor_running(), + }, + }, + } + case "stop": + await self.end_icloud_monitor() + return { + "command": command, + "command_class": "icloud_monitor_control", + "command_status": "OK", + "data": { + "icloud": { + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_icloud_monitor_running(), + }, + }, + } + case "status": + return { + "command": command, + "command_class": "icloud_monitor_control", + "command_status": "OK", + "data": { + "icloud": { + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_icloud_monitor_running(), + }, + }, + } + case "refresh": + resp = { + "command": command, + "command_class": "icloud_monitor_control", + "command_status": "OK", + "data": { + "icloud": { + "icloud_monitor_enabled": self.context.icloud_monitor_enabled, + "icloud_monitor_running": self.is_icloud_monitor_running(), + "fmf_location": json.dumps( + await self.refresh_icloud_location(), + default=json_fallback, + ), + }, + }, + } + return resp + case _: + return { + "command": command, + "command_class": "icloud_monitor_control", + "command_status": "ERROR", + "message": "Invalid command", + } + finally: + await sio_send_status(sid) + + @sio.event + async def tunneld_control(sid, data): + command = ( + data.get("command") + if isinstance(data, dict) + else getattr(data, "command", None) + ) + logger.info("Tunneld Control command: %s requested from %s", command, sid) + match command: + case "start": + logger.info("Start tunneld request from %s: %s", sid, data) + try: + self.tunneld_core.start() + logger.info("Tunneld started successfully") + return { + "command_status": "OK", + "command_class": "tunneld_control", + "command": command, + "message": "Tunneld started successfully", + } + except Exception as e: + logger.error("Error starting tunneld: %s", e) + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "ERROR", + "message": f"Error starting tunneld: {e}", + } + case "restart": + logger.info("Restart tunneld request from %s: %s", sid, data) + try: + self.tunneld_core.clear() + await asyncio.sleep(2) + self.tunneld_core.start() + return { + "command_status": "OK", + "command_class": "tunneld_control", + "command": command, + "message": "Tunneld started successfully", + } + except Exception as e: + logger.error("Error restarting tunneld: %s", e) + return { + "command_status": "ERROR", + "command_class": "tunneld_control", + "command": command, + "message": f"Error starting tunneld: {e}", + } + + case "start-watcher": + logger.info("Start tunneld watcher request from %s: %s", sid, data) + await self.start_tunnel_watcher() + return { + "command_status": "OK", + "command_class": "tunneld_control", + "command": command, + "message": "Tunneld watcher started successfully", + } + + case "end-watcher": + logger.info("End tunneld watcher request from %s: %s", sid, data) + await self.end_tunnel_watcher() + return { + "command_status": "OK", + "command_class": "tunneld_control", + "command": command, + "message": "Tunneld watcher stopped successfully", + } + + case "shutdown": + logger.info("Shutdown tunneld request from %s: %s", sid, data) + try: + os.kill(os.getpid(), signal.SIGINT) + return { + "command_status": "OK", + "command_class": "tunneld_control", + "command": command, + "message": "Server shutting down...", + } + except Exception as e: + logger.error("Error shutting down tunneld: %s", e) + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "ERROR", + "message": f"Error shutting down tunneld: {e}", + } + + case "clear": + logger.info("Clearing tunnels...") + try: + self.tunneld_core.clear() + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "OK", + "message": "Cleared tunnels...", + } + except Exception as e: + logger.error("Error clearing tunnels: %s", e) + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "ERROR", + "message": f"Error clearing tunnels: {e}", + } + + case "cancel": + logger.info("Canceling tunnel request from %s: %s", sid, data) + try: + udid = ( + data.get("udid") + if isinstance(data, dict) + else getattr(data, "udid", self.context.udid) + ) + if udid is None: + udid = self.context.udid + self.tunneld_core.cancel(udid=udid) + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "OK", + "udid": udid, + "message": f"tunnel {udid} Canceled ...", + } + except Exception as e: + logger.error("Error canceling tunnel: %s", e) + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "ERROR", + "message": f"Error canceling tunnel: {e}", + } + + case _: + return { + "command": command, + "command_class": "tunneld_control", + "command_status": "ERROR", + "message": f"Unknown operation: {command}", + } + + @sio.event + async def reverse_geocode(sid, data: LatLng): + logger.info("OSM Proxy Request from %s, data: %s", sid, data) + return await self.get_reverse_geocode(data) + + @sio.event + async def favorite_control(sid, data: dict): + command = ( + data.get("command") + if isinstance(data, dict) + else getattr(data, "command", None) + ) + logger.info("Favorite Control command: %s requested from %s", command, sid) + match command: + case "set": + cstat, cmessage = await self.set_favorite(data) + if cstat == "ERROR": + logger.error("Favorite set failed: %s", cmessage) + return { + "command": command, + "command_class": "favorite_control", + "command_status": cstat, + "message": cmessage, + "data": { + "favorites": await self.get_favorites(), + }, + } + + case "clear": + cstat, cmessage = await self.clear_favorite(data) + return { + "command": command, + "command_class": "favorite_control", + "command_status": cstat, + "message": cmessage, + "data": { + "favorites": await self.get_favorites(), + }, + } + + case "get": + favs = await self.get_favorites() + if favs: + cstat = "OK" + cmessage = "Favorites retrieved successfully" + else: + cstat = "ERROR" + cmessage = "No favorites found" + return { + "command": command, + "command_class": "favorite_control", + "command_status": cstat, + "message": cmessage, + "data": { + "favorites": favs, + }, + } + + case _: + return { + "command": command, + "command_class": "favorite_control", + "command_status": "ERROR", + "message": f"Invalid command: {command}", + } + + @sio.event + async def category_control(sid, data: dict): + command = ( + data.get("command") + if isinstance(data, dict) + else getattr(data, "command", None) + ) + logger.info("Category Control command: %s requested from %s", command, sid) + match command: + case "add": + cstat, cmessage = await self.add_category(data) + if cstat == "ERROR": + logger.error("Category set failed: %s", cmessage) + return { + "command": command, + "command_class": "category_control", + "command_status": cstat, + "message": cmessage, + "data": { + "categories": await self.get_categories(), + }, + } + + case "delete": + cstat, cmessage = await self.delete_category(data) + return { + "command": command, + "command_class": "category_control", + "command_status": cstat, + "message": cmessage, + "data": { + "categories": await self.get_categories(), + }, + } + + case "get": + cats = await self.get_categories() + if cats: + cstat = "OK" + cmessage = "Categories retrieved successfully" + else: + cstat = "ERROR" + cmessage = "No categories found" + logger.info("Categories: %s", cats) + return { + "command": command, + "command_class": "category_control", + "command_status": cstat, + "message": cmessage, + "data": { + "categories": cats, + }, + } + + case _: + return { + "command": command, + "command_class": "category_control", + "command_status": "ERROR", + "message": f"Invalid command: {command}", + } diff --git a/src/pymd3_vue_location_sim/state.py b/src/pymd3_vue_location_sim/state.py new file mode 100644 index 0000000..d3c4e49 --- /dev/null +++ b/src/pymd3_vue_location_sim/state.py @@ -0,0 +1,85 @@ +import asyncio +import os +from typing import Dict, Optional + +import socketio + +from .geo_cache import AsyncReverseGeocoder +from .icloud_monitor import FindMyMonitor +from .models import iCloudLocationData + + +ACTIVE_SIMULATION_STATES = {"RUNNING", "PAUSED"} + + +def env_flag(name: str, default: str = "True") -> bool: + return os.getenv(name, default).lower() == "true" + + +class LocationSimulationState: + def __init__(self): + self.connected_clients: set[str] = set() + self.current_location: Optional[Dict[str, str]] = None + self.device_name: Optional[str] = os.getenv("SELECTED_DEVICE_NAME") + self.fmf_location: Optional[iCloudLocationData] = None + self.fmf_queue: asyncio.Queue = asyncio.Queue() + self.icloud_consumer_task = None + self.icloud_monitor = FindMyMonitor(self.fmf_queue) + self.icloud_monitor_task = None + self.icloud_monitor_enabled: bool = False + self.tunnel_watcher_task = None + self.next_move: Optional[float] = None + self.simulation_queue: asyncio.Queue = asyncio.Queue() + self.simulation_queue_data: Dict = {} + self.simulation_queue_order: list[str] = [] + self.simulation_queue_deleted_items: list[str] = [] + self.simulation_queue_pending_ids: set[str] = set() + self.simulation_queue_state: str = "STOPPED" + self.simulation_noise: bool = True + self.simulation_task: Optional[asyncio.Task] = None + self.sio: socketio.AsyncServer = socketio.AsyncServer( + async_mode="asgi", cors_allowed_origins="*" + ) + self.test_mode: bool = env_flag("TEST_MODE", "True") + self.tunnel = None + self.udid: Optional[str] = None + self.reverse_geocode = AsyncReverseGeocoder() + + @property + def simulation_active(self) -> bool: + task_active = ( + self.simulation_task is not None + and not self.simulation_task.done() + ) + return self.simulation_queue_state in ACTIVE_SIMULATION_STATES and task_active + + @simulation_active.setter + def simulation_active(self, active: bool) -> None: + if active: + if self.simulation_queue_state not in ACTIVE_SIMULATION_STATES: + self.simulation_queue_state = "RUNNING" + elif self.simulation_queue_state != "SHUTDOWN": + self.simulation_queue_state = "STOPPED" + + def get_current_loc_id(self) -> Optional[str]: + if not isinstance(self.current_location, dict): + return None + loc_id = self.current_location.get("loc_id") + if isinstance(loc_id, str) and loc_id: + return loc_id + return None + + def set_current_loc_id(self, loc_id: Optional[str]) -> None: + if isinstance(loc_id, str) and loc_id: + self.current_location = {"loc_id": loc_id} + else: + self.current_location = None + + def get_current_item(self) -> Optional[dict]: + current_loc_id = self.get_current_loc_id() + if current_loc_id is None: + return None + item = self.simulation_queue_data.get(current_loc_id) + if isinstance(item, dict): + return item + return None diff --git a/src/pymd3_vue_location_sim/tunnel_service.py b/src/pymd3_vue_location_sim/tunnel_service.py new file mode 100644 index 0000000..a2d2cfd --- /dev/null +++ b/src/pymd3_vue_location_sim/tunnel_service.py @@ -0,0 +1,522 @@ +import asyncio +import json +import os +import signal +import sys +import traceback +from contextlib import suppress +from typing import Dict, Optional + +import fastapi +from pymobiledevice3.exceptions import ( + ConnectionFailedError, + InvalidServiceError, + MuxException, + TunneldConnectionError, +) +from pymobiledevice3.lockdown import create_using_usbmux +from pymobiledevice3.remote.common import TunnelProtocol +from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService +from pymobiledevice3.remote.tunnel_service import ( + CoreDeviceTunnelProxy, + TunnelResult, + create_core_device_tunnel_service_using_rsd, + get_remote_pairing_tunnel_services, +) +from pymobiledevice3.remote.utils import get_rsds +from pymobiledevice3.tunneld.server import TunnelTask + +from .json_formatter import logger + + +class TunnelService: + def __init__(self, context, tunneld_core): + self.context = context + self.tunneld_core = tunneld_core + self.simulation_service = None + self.icloud_service = None + + def bind_services(self, simulation_service, icloud_service) -> None: + self.simulation_service = simulation_service + self.icloud_service = icloud_service + + async def get_tun( + self, + udid: Optional[str] = None, + max_retries: int = 10, + retry_delay: float = 0.5, + ) -> RemoteServiceDiscoveryService: + """Resolve an active local tunnel and connect directly to its RSD endpoint.""" + if self.context.tunnel is not None: + return self.context.tunnel + + for attempt in range(max_retries): + candidates = [] + for active_tunnel in self.tunneld_core.tunnel_tasks.values(): + if active_tunnel.tunnel is None or active_tunnel.udid is None: + continue + if udid is None or active_tunnel.udid == udid: + candidates.append(active_tunnel.tunnel) + + if not candidates: + if attempt < max_retries - 1: + logger.info( + "Waiting for local tunnel to be ready... (attempt %s/%s)", + attempt + 1, + max_retries, + ) + await asyncio.sleep(retry_delay) + continue + logger.error("Failed to find an active local tunnel after max retries") + raise TunneldConnectionError() + + tunnel = candidates[0] + if udid and len(candidates) > 1: + logger.warning( + "Multiple local tunnels found for udid %s; using first available", + udid, + ) + + try: + rsd = RemoteServiceDiscoveryService((tunnel.address, tunnel.port)) + await rsd.connect() + logger.info( + "Connected to local tunnel at %s:%s after %s retries", + tunnel.address, + tunnel.port, + attempt, + ) + self.context.tunnel = rsd + return rsd + except Exception: + if attempt < max_retries - 1: + logger.info( + "Tunnel endpoint not ready yet... (attempt %s/%s)", + attempt + 1, + max_retries, + ) + await asyncio.sleep(retry_delay) + else: + logger.error( + "Failed to connect to local tunnel endpoint after max retries" + ) + raise TunneldConnectionError() + + raise TunneldConnectionError() + + def collect_active_tunnels(self) -> Dict[str, dict]: + active: Dict[str, dict] = {} + for interface, tunnel_task in self.tunneld_core.tunnel_tasks.items(): + if tunnel_task is None or tunnel_task.tunnel is None or tunnel_task.udid is None: + continue + tunnel = tunnel_task.tunnel + active[str(interface)] = { + "interface": str(interface), + "udid": tunnel_task.udid, + "address": getattr(tunnel, "address", None), + "port": getattr(tunnel, "port", None), + "transport": getattr(tunnel, "protocol", None), + } + return active + + async def handle_tunnel_drop(self, disconnected: list[dict]) -> None: + disconnected_udids: set[str] = set() + for item in disconnected: + udid = item.get("udid") + if isinstance(udid, str) and udid: + disconnected_udids.add(udid) + if not disconnected_udids: + return + current_active = self.collect_active_tunnels() + active_udids: set[str] = set() + for item in current_active.values(): + udid = item.get("udid") + if isinstance(udid, str) and udid: + active_udids.add(udid) + primary_disconnected = ( + self.context.udid in disconnected_udids if self.context.udid else False + ) + no_tunnels_left = len(current_active) == 0 + if not primary_disconnected and not no_tunnels_left: + return + logger.warning( + "Tunnel drop detected. primary_udid=%s disconnected_udids=%s active_udids=%s", + self.context.udid, + sorted(disconnected_udids), + sorted(active_udids), + ) + if self.context.tunnel is not None: + with suppress(Exception): + await self.context.tunnel.close() + self.context.tunnel = None + if no_tunnels_left: + self.context.udid = None + if self.context.simulation_active and self.simulation_service is not None: + await self.simulation_service.pause_queue() + await self.context.sio.emit( + "appError", + { + "type": "tunnel_disconnected", + "message": "Tunnel dropped. Simulation paused until reconnection.", + "disconnected_udids": sorted(disconnected_udids), + }, + namespace="/", + ) + if self.icloud_service is not None: + await self.icloud_service.stop() + + async def tunnel_watcher_loop(self) -> None: + previous = self.collect_active_tunnels() + while True: + try: + await asyncio.sleep(1) + current = self.collect_active_tunnels() + added_keys = set(current.keys()) - set(previous.keys()) + removed_keys = set(previous.keys()) - set(current.keys()) + added = [current[k] for k in sorted(added_keys)] + removed = [previous[k] for k in sorted(removed_keys)] + for item in added: + try: + logger.info( + "Tunnel discovered interface=%s udid=%s address=%s port=%s transport=%s", + item.get("interface"), + item.get("udid"), + item.get("address"), + item.get("port"), + item.get("transport"), + ) + await self.safe_sio_emit("tunnel_device_connected", item) + logger.info( + "Current udid: %s, new udid: %s", + self.context.udid, + item.get("udid"), + ) + if self.context.udid is None: + self.context.udid = item.get("udid") + device_name = await self.get_device_name() + selected_device_name = self.context.device_name + if selected_device_name and device_name != selected_device_name: + wrong_udid = self.context.udid + self.tunneld_core.cancel(udid=item.get("udid")) + logger.warning( + "Tunnel established to wrong device. Dropping tunnel. wrong_udid=%s for device: %s", + wrong_udid, + selected_device_name, + ) + self.context.udid = None + await self.safe_sio_emit( + "appError", + { + "type": "tunnel_wrong_device", + "message": "Tunnel established to wrong device. Dropping tunnel.", + "wrong_udid": wrong_udid, + "device_name": device_name, + }, + ) + await self.get_tun( + item.get("udid"), max_retries=10, retry_delay=1.0 + ) + except Exception: + logger.exception( + "Tunnel watcher failed while handling added tunnel for udid=%s", + item.get("udid"), + ) + + if removed: + for item in removed: + try: + logger.warning( + "Tunnel disconnected interface=%s udid=%s address=%s port=%s", + item.get("interface"), + item.get("udid"), + item.get("address"), + item.get("port"), + ) + await self.safe_sio_emit( + "tunnel_device_disconnected", item + ) + except Exception: + logger.exception( + "Tunnel watcher failed while emitting disconnection for udid=%s", + item.get("udid"), + ) + await self.handle_tunnel_drop(removed) + + previous = current + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Tunnel watcher loop crashed; continuing") + + async def start_watcher(self) -> None: + if ( + self.context.tunnel_watcher_task is None + or self.context.tunnel_watcher_task.done() + ): + if ( + self.context.tunnel_watcher_task is not None + and self.context.tunnel_watcher_task.done() + ): + with suppress(Exception): + exc = self.context.tunnel_watcher_task.exception() + if exc is not None: + logger.error( + "Previous tunnel watcher task exited with error: %s", exc + ) + self.context.tunnel_watcher_task = asyncio.create_task( + self.tunnel_watcher_loop(), + name="tunnel-task-watcher", + ) + + async def stop_watcher(self) -> None: + if self.context.tunnel_watcher_task is not None: + self.context.tunnel_watcher_task.cancel() + with suppress(asyncio.CancelledError): + await self.context.tunnel_watcher_task + self.context.tunnel_watcher_task = None + + @staticmethod + def parse_data(d): + mydict = {} + for key, value in d.items(): + if isinstance(value, dict): + TunnelService.parse_data(value) + elif isinstance(value, (str, int, float, bool, list)): + mydict[key] = value + elif isinstance(value, bytes): + mydict[key] = "BYTE DATA" + else: + mydict[key] = value + return mydict + + async def get_device_name(self): + if self.context.tunnel is None: + await self.get_tun() + device_name = await self.context.tunnel.get_value(key="DeviceName") + return device_name + + async def safe_sio_emit( + self, event: str, payload: dict, timeout_seconds: float = 2.0 + ) -> None: + try: + await asyncio.wait_for( + self.context.sio.emit(event, payload, namespace="/"), + timeout=timeout_seconds, + ) + except TimeoutError: + logger.warning("Socket.IO emit timed out for event=%s", event) + except Exception: + logger.exception("Socket.IO emit failed for event=%s", event) + + async def device_reboot(self, delay): + logger.info("Reboot initiated with delay %s", delay) + await asyncio.sleep(delay) + os.system("shutdown -r now") + + async def device_shutdown(self, delay): + logger.info("Shutdown initiated with delay %s", delay) + await asyncio.sleep(delay) + os.system("shutdown -h now") + + async def device_info(self): + tunnels = {} + for _, active_tunnel in self.tunneld_core.tunnel_tasks.items(): + if (active_tunnel.udid is None) or (active_tunnel.tunnel is None): + continue + if active_tunnel.udid not in tunnels: + tunnels[active_tunnel.udid] = {} + try: + lockdown = await create_using_usbmux( + serial=active_tunnel.udid, autopair=False + ) + tunnels[active_tunnel.udid] = self.parse_data(lockdown.all_values) + except Exception as e: + logger.error( + "Failed to create lockdown session for device %s: %s", + active_tunnel.udid, + e, + ) + continue + return tunnels + + def list_tunnels(self) -> dict[str, list[dict]]: + tunnels = {} + for ip, active_tunnel in self.tunneld_core.tunnel_tasks.items(): + if (active_tunnel.udid is None) or (active_tunnel.tunnel is None): + continue + if active_tunnel.udid not in tunnels: + tunnels[active_tunnel.udid] = [] + tunnels[active_tunnel.udid].append( + { + "tunnel-address": active_tunnel.tunnel.address, + "tunnel-port": active_tunnel.tunnel.port, + "interface": ip, + } + ) + return tunnels + + @staticmethod + async def shutdown_process_response() -> dict: + os.kill(os.getpid(), signal.SIGINT) + return { + "operation": "shutdown", + "data": True, + "message": "Server shutting down...", + } + + def clear_tunnels_response(self) -> dict: + self.tunneld_core.clear() + return { + "operation": "clear_tunnels", + "data": True, + "message": "Cleared tunnels...", + } + + def cancel_tunnel_response(self, udid: str) -> dict: + self.tunneld_core.cancel(udid=udid) + return { + "operation": "cancel", + "udid": udid, + "data": True, + "message": f"tunnel {udid} Canceled ...", + } + + async def start_tunnel_response( + self, + udid: Optional[str] = None, + ip: Optional[str] = None, + connection_type: Optional[str] = None, + ): + if udid is None: + udid = self.context.udid + udid_tunnels = [ + t.tunnel + for t in self.tunneld_core.tunnel_tasks.values() + if t.udid == udid and t.tunnel is not None + ] + if len(udid_tunnels) > 0: + self.context.udid = udid + return { + "interface": udid_tunnels[0].interface, + "port": udid_tunnels[0].port, + "address": udid_tunnels[0].address, + } + queue = asyncio.Queue() + created_task = False + try: + if not created_task and connection_type in ("usbmux", None): + task_identifier = f"usbmux-{udid}" + try: + async with await create_using_usbmux(udid) as lockdown: + service = await CoreDeviceTunnelProxy.create(lockdown) + task = asyncio.create_task( + self.tunneld_core.start_tunnel_task( + task_identifier, + service, + protocol=TunnelProtocol.TCP, + queue=queue, + ), + name=f"start-tunnel-task-{task_identifier}", + ) + self.tunneld_core.tunnel_tasks[task_identifier] = TunnelTask( + task=task, udid=udid + ) + created_task = True + except (ConnectionFailedError, InvalidServiceError, MuxException): + pass + if connection_type in ("usb", None): + for rsd in await get_rsds(udid=udid): + rsd_ip = rsd.service.address[0] + if ip is not None and rsd_ip != ip: + await rsd.close() + continue + task = asyncio.create_task( + self.tunneld_core.start_tunnel_task( + rsd_ip, + await create_core_device_tunnel_service_using_rsd(rsd), + queue=queue, + ), + name=f"start-tunnel-usb-{rsd_ip}", + ) + self.tunneld_core.tunnel_tasks[rsd_ip] = TunnelTask( + task=task, udid=rsd.udid + ) + created_task = True + if not created_task and connection_type in ("wifi", None): + for remotepairing in await get_remote_pairing_tunnel_services( + udid=udid + ): + remotepairing_ip = remotepairing.hostname + if ip is not None and remotepairing_ip != ip: + await remotepairing.close() + continue + task = asyncio.create_task( + self.tunneld_core.start_tunnel_task( + remotepairing_ip, remotepairing, queue=queue + ), + name=f"start-tunnel-wifi-{remotepairing_ip}", + ) + self.tunneld_core.tunnel_tasks[remotepairing_ip] = TunnelTask( + task=task, udid=remotepairing.remote_identifier + ) + created_task = True + except Exception as e: + return fastapi.Response( + status_code=501, + content=json.dumps( + { + "error": { + "exception": e.__class__.__name__, + "traceback": traceback.format_exc(), + } + } + ), + ) + if not created_task: + return fastapi.Response( + status_code=501, content=json.dumps({"error": "task not created"}) + ) + tunnel: Optional[TunnelResult] = await queue.get() + if tunnel is not None: + self.context.udid = udid + return { + "interface": tunnel.interface, + "port": tunnel.port, + "address": tunnel.address, + } + return fastapi.Response( + status_code=404, + content=json.dumps( + {"error": "something went wrong during tunnel creation"} + ), + ) + + async def restart_response(self) -> dict: + self.tunneld_core.clear() + await asyncio.sleep(2) + self.tunneld_core.start() + return { + "operation": "restart-tunneld", + "data": True, + "message": "Restarting tunneld...", + } + + async def _app_restart(self) -> None: + logger.info("Restarting application...") + await self.stop_watcher() + await self.icloud_service.stop() + await self.simulation_service.empty_queue() + await self.tunneld_core.close() + await self.context.sio.shutdown() + try: + sys.stdout.flush() + sys.stderr.flush() + except Exception: + pass + try: + max_fds = os.sysconf("SC_OPEN_MAX") + except (AttributeError, ValueError): + max_fds = 1024 + + os.closerange(3, max_fds) + os.execv(sys.executable, [sys.executable] + sys.argv) \ No newline at end of file diff --git a/uv.lock b/uv.lock index b0d237e..8ee3e58 100644 --- a/uv.lock +++ b/uv.lock @@ -1356,6 +1356,7 @@ dependencies = [ { name = "sqlalchemy-orm" }, { name = "typer" }, { name = "typing" }, + { name = "uptime" }, { name = "uvicorn" }, ] @@ -1376,6 +1377,7 @@ requires-dist = [ { name = "sqlalchemy-orm", specifier = ">=1.2.10" }, { name = "typer", specifier = ">=0.24.1" }, { name = "typing", specifier = "==3.10.0.0" }, + { name = "uptime", specifier = ">=3.0.1" }, { name = "uvicorn", specifier = "==0.41.0" }, ] @@ -1893,6 +1895,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "uptime" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/53/6c420ddf6949097d6f9406358951c9322505849bea9cb79efe3acc0bb55d/uptime-3.0.1.tar.gz", hash = "sha256:7c300254775b807ce46e3dcbcda30aa3b9a204b9c57a7ac1e79ee6dbe3942973", size = 6630, upload-time = "2013-10-07T14:19:58.456Z" } + [[package]] name = "urllib3" version = "2.6.3"