From 9dac5fe744825169a00f4e48a423c7524866b9a3 Mon Sep 17 00:00:00 2001 From: William Bruno Date: Wed, 10 Jun 2026 11:06:33 -0400 Subject: [PATCH] milestones --- .codex | 0 .gitignore | 4 + .idea/pyLspTools.xml | 34 + .idea/pymd3_vue_location_sim.iml | 3 + __pycache__/main.cpython-314.pyc | Bin 4065 -> 4130 bytes geocache.db | Bin 208896 -> 643072 bytes pyproject.toml | 1 + src/pymd3_vue_location_sim/geo_cache.py | 306 +- src/pymd3_vue_location_sim/http_routes.py | 256 ++ src/pymd3_vue_location_sim/icloud_monitor.py | 85 +- src/pymd3_vue_location_sim/icloud_service.py | 114 + src/pymd3_vue_location_sim/models.py | 17 +- src/pymd3_vue_location_sim/server.py | 2551 +---------------- .../simulation_service.py | 1030 +++++++ src/pymd3_vue_location_sim/sio_events.py | 782 +++++ src/pymd3_vue_location_sim/state.py | 85 + src/pymd3_vue_location_sim/tunnel_service.py | 522 ++++ uv.lock | 8 + 18 files changed, 3275 insertions(+), 2523 deletions(-) create mode 100644 .codex create mode 100644 .idea/pyLspTools.xml create mode 100644 src/pymd3_vue_location_sim/http_routes.py create mode 100644 src/pymd3_vue_location_sim/icloud_service.py create mode 100644 src/pymd3_vue_location_sim/simulation_service.py create mode 100644 src/pymd3_vue_location_sim/sio_events.py create mode 100644 src/pymd3_vue_location_sim/state.py create mode 100644 src/pymd3_vue_location_sim/tunnel_service.py 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 7147ec652486ed79348d369c8a01db1576a46578..ef08a1d416190c6d5d23fbd7549c1e915f20f9c2 100644 GIT binary patch delta 758 zcmYjOOK1~882)E>vzugJO>AmgT9Z()#?~}K(SoH_sJbh~#-+ZX=^9MXNV^G>saQeU zvxqh9#bfEsgLueYZ{o>I4~1!iZ}3>`#hcD-6ZD7q{_lUx|Csrb+)C*qh8_hbAErN7 zo;376^t&;fTSK-?e3OCwizGA9Z9l_WEMog@j{F+yW@EAqSuk_hs~U zO=ejlI|p|dtiu9gDv$-C!}3yx{nKf1z(M2fobmRp8Z0QC97nhTj(E16!8VYFAQPfe ze$JV%{CNlPSjlB`Ygq;vflBwQbwZ_D6_cwOiHa4EP@!6<^1U)Cdk?94cd6o%70)X# zl`;q$tV^9YO;KgJSn;y8RVs0UyO~qx(KLf~*i(&Xcw_bH>dT(Z#I`xSt)5t)*^|r{ zw>Rf^q>~2-nj6BPh;;vTXdD^-*U&fA>%W)3hp@>C_-~YS?-53*=DJ1CtCAuqyDlDP zu2}P@Vj+}ho{o$OsNXND=TXYvQhm9PMe!;BoVGZ{M+v6Ifs_9o9^f#@;TVS`hZF;9 zv=?+SUlg+vJKCtp+v9i#V$HbTkHj8)#ve1%@(}lT`PYo?2Yhp?%v`*5;ri@+aDj$h z*DEfwfnNbm@XTP9@Z@r}w6av5!dIA&zheCngMASpvhzqe}K+xTl6jSoA*6$cAn?I=ERaDCV|M8H?OUm z`{EQD?_a9!GWEE|Vm1d&&|`H@OW;JP$LmRr$9$->bwLxcs7Y7~ZLThB3RZHUC(?jZ z4ZaEGY1;Zoh>iZY^<;y+HFFaEx9hylS1B4&0cywoxr8pHK^N1|Rk{dIG*0W%{9S+! znLPuIlrN!|yD^d+CF?Lxum|@M*+S7lM|$LPP|j(F?9=||Y_u^?(W*#E^A&Zg)*f?>2A$Hgsd8isd~xa8psry-;a?SvXZ zY5ysI6=nTTydOV9qF%}07MhFnJWr$uooT8~QPB7D&QWA2vIK#2{0|IrXUFtNiwUtT z)8jqq1oCiIoJP?}ujX$_1(f%nNbeue>jmufr5o4QS8j*70cRLa%cir+v{a!8FTyR@ zZfDRn7rkZTqc7JV5R9V;p#zXdkR3th0E8bfKZ4~ET=@)lMzHXkL8v_D{CDyiL!12t D&a9%^ diff --git a/geocache.db b/geocache.db index 3ddbc833f66bfd2db5357f24690d7d56bc4fc7be..cddc8d24cee30f0dd8290c33bb28c6d778e545b6 100644 GIT binary patch literal 643072 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 delta 11320 zcmb_?cXU<7*Y=q?{niB1NgxR|lyG|y5s)suOI4)z4xxrKK~TgRWqv4v43J-eiYSfeU8yM~1vX7R|OGbc?QGkrv{*}LuCy&F5s?matpY1OlTVW+nJ8x)Tp zI{lvUlO`6II7&qSUTLAU&Pba{>R+Tw>lUSZiD}Jd9aVot52mKjxyF0@e!M?V%A`8Xx3L&-FDWrk zAz|P=76q;@B{r!f6Sy{u0N0fgyERf1xF-v%{`+MqaZ@KF;jI4*mi*6@~zBv^cfW0JM zvpG30tksj&{5ynoTtzj&u*7p)kR!_vP_1S{^8Ckr(zVz5iDS9h)iAU#)fbhY>X+=8j>L&5cEy+2ftRgLm-fu+^;F%R(4BM0vi91OeT;r(lz&uS#eA}9+ zh|P`_Zq?7GNPK@A@|npBD_m$^OG>=fmefG=<`quh{!(IRI}!uFv% OiHA6AkBf> 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
Qh-vbcp}W>F7mLth#Kq?RaVFyQ9> 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.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"