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