From 47aeebd86f5afbf30cb1e54fb7e1f26d94d178a9 Mon Sep 17 00:00:00 2001 From: William Bruno Date: Sat, 21 Mar 2026 07:32:43 -0400 Subject: [PATCH] lots of changes --- __pycache__/main.cpython-314.pyc | Bin 5304 -> 5343 bytes __pycache__/server.cpython-314.pyc | Bin 40578 -> 72761 bytes database.py | 27 + db_models.py | 58 ++ icloud.py | 88 +++ server.py | 323 ++++++--- server_recover.py | 1074 ++++++++++++++++++++++++++++ 7 files changed, 1463 insertions(+), 107 deletions(-) create mode 100644 database.py create mode 100644 db_models.py create mode 100644 icloud.py create mode 100644 server_recover.py diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc index b1ebd9af79321deb4ce4a91f47813c1d2d96f2ad..b59854b97924baaa0cc3732b64b7c4eb09a95ebd 100644 GIT binary patch delta 66 zcmdm?d0&%Tn~#@^0SGb|Z`{bO&8nfNpOK%Ns$ZT|RGOEspPN{qTb!Azo0FfMSdy8a Ur=OIVoUNOhm!iMfoVAf30GPHFBLDyZ delta 27 hcmcbwxkHm%n~#@^0SFSlY}&}J&C1BWIgYiF9{^)12IT+% diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc index 396ca7424ceb0845d63a88c9bc4147f7627fa34b..d4b7bd11078bc7bc431ec7ec56c45690a1de06b0 100644 GIT binary patch literal 72761 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 delta 18085 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 diff --git a/database.py b/database.py new file mode 100644 index 0000000..9e6c968 --- /dev/null +++ b/database.py @@ -0,0 +1,27 @@ +from sqlalchemy import create_engine +from dqlalchemy.orm import sessionmaker, declaritive_base + +DATABASE_URL = "sqlite:///./locations.db" + +engine = create_engine( + DATABASE_URL, connect_engine("check_same_thread": false} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + """Dependency for getting database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + +def create_all_tables(): + """Creates all tables defined with Base in the database.""" + # Note: import models before calling create_all_tables() + Base.metadata_create_all(bind=engine) + print("Database and tables created.") + diff --git a/db_models.py b/db_models.py new file mode 100644 index 0000000..fa4af67 --- /dev/null +++ b/db_models.py @@ -0,0 +1,58 @@ +from sqlalchemy import Column, Integer, String, Float, ForeignKey +from sqlalchemy.orm import relationship +from .database import Base + + +class Location(Base): + """ + SQLAlchemy model for the 'locations' table. + """ + __tablename__ = "locations" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=False, index=True) + address = Column(String, unique=False, index=True) + latitude = Column(Float, unique=False, index=True) + longitude = Column(Float, unique=False, index=True) + is_favorite = Column(Boolean, unique=False, index=True) + + def __repr__(self): + return f"" + +class Route(Base): + """ + SQLAlchemy model for the 'routes' table. + """ + __tablename__ = "routes" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + + # Start and Endpoints (One-to-Many relationship) + start_location_id = Column(Integer, ForeignKey('locations.id')) + end_location_id = Column(Integer, ForeignKey('locations.id')) + + start_location = relationship("Location", foreign_keys=[start_id]) + end_location = relationship("Location", foreign_keys=[end_id]) + + # Relationship to get waypoints ordered + waypoints = relationship("Waypoint", order_by="Waypoint.order", back_populates="route") + + + def __repr__(self): + return f"" + +# Association Table for Many-to-Many relationsjip (Routes <-> Waypoints) +class Waypoint(Base): + """ + SQLAlchemy model for the 'waypoints' table. + """ + __tablename__ = 'waypoints' + + id = Column(Integer, primary_key=True) + route_id = Column(Integer, ForeignKey('routes.id')) + location_id = Column(Integer, ForeignKey('locations.id')) + order = Column(Integer, nullable=False) + + route = relationship("Route", back_populates="waypoints") + location = relationship("Location") diff --git a/icloud.py b/icloud.py new file mode 100644 index 0000000..3360fd5 --- /dev/null +++ b/icloud.py @@ -0,0 +1,88 @@ +import asyncio +import json +import os +from pyicloud import PyiCloudService +from pyicloud.exceptions import PyiCloud2FARequiredException + +class FindMyMonitor: + def __init__(self, username, password, queue: asyncio.Queue, token_file="icloud_token.txt"): + self.username = username + self.password = password + self.token_file = token_file + self.queue = queue + self.api = None + self.device = None + self.running = True + + async def authenticate(self): + """Authenticates with iCloud, handling 2FA and token storage.""" + if os.path.exists(self.token_file): + print("Loading stored session...") + self.api = PyiCloudService(self.username, cookie_directory="./cookies") + else: + print("No stored session. Authenticating...") + self.api = PyiCloudService(self.username, self.password, cookie_directory="./cookies") + + if self.api.requires_2fa: + print("Two-factor authentication required.") + code = input("Enter the code you received: ") + result = self.api.validate_2fa_code(code) + print(f"Code validation result: {result}") + if not result: + print("Failed to verify 2FA code") + return False + + # Trust the session + self.api.trust_session() + + print("Successfully authenticated.") + return True + + async def get_location(self): + """Fetches the latest latitude and longitude.""" + if not self.api: + await self.authenticate() + + # Refresh API data + self.api.refresh_client() + + # Find the device (modify name to match your iPhone name in iCloud) + if not self.device: + # Assuming you have devices, pick the first or match by name + self.device = self.api.devices[0] + print(f"Monitoring device: {self.device.name()}") + + location = self.device.location() + if location: + return location['latitude'], location['longitude'], location['timeStamp'] + return None + + def start(self): + self.running = True + + def stop(self): + self.running = False + + async def run_monitor(self, interval=60): + """Runs the monitor loop.""" + if not await self.authenticate(): + return + + if not self.running: + self.start() + + while self.running: + try: + lat, lon, ts = await self.get_location() + print(f"[{ts}] Location: {lat}, {lon}") + # Add your logic to update database/API here + await self.queue.put(lat, lng, ts) + + except Exception as e: + print(f"Error: {e}") + # Re-authenticate if session expired + await self.authenticate() + + await asyncio.sleep(interval) + + diff --git a/server.py b/server.py index 0e46e6b..8c1a669 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,6 @@ import asyncio +from icloud import FindMyMonitor from datetime import datetime, timezone, timedelta import json import uuid @@ -9,11 +10,13 @@ import signal import traceback import warnings import random +from operator import truediv + from pydantic import BaseModel, RootModel import socketio from contextlib import asynccontextmanager, suppress -from typing import Optional +from typing import Optional, Dict from pymobiledevice3.services.dvt.instruments.location_simulation_base import LocationSimulationBase @@ -129,24 +132,37 @@ class SimulationRequestResponse(BaseModel): status: bool data: Optional[SimulationRequestResponseData] +class SimulationQueueDict(BaseModel): + location_id: Dict[str, SimulationRequestResponseData] + +class iCloudLocationData(BaseModel): + latitude: number + longitude: number + timestamp: string + class LocationSimulationState: def __init__(self): + self.current_location: Optional[Dict[str, SimulationRequestResponseData]] = None + self.next_location: Optional[Dict[str, SimulationRequestResponseData]] = None + self.loc_id: Optional[str] = None self.latitude: Optional[float] = None self.longitude: Optional[float] = None self.next_move: Optional[float] = None self.udid: Optional[str] = None self.simulation_active: bool = False - self.loc_id: Optional[str] = None self.set_location_enabled: bool = True self.queue: asyncio.Queue = asyncio.Queue() - self.queue_list: list[SimulationRequestResponseData] = [] + self.queue_order: list[str] = [] + self.queue_data: Dict = {} self.queue_status: Optional[asyncio.Event] = asyncio.Event() self.queue_state: str = "STOPPED" - self.test_mode: bool = True + self.test_mode: bool = False self.simulation_task: Optional[asyncio.Task] = None self.sio: socketio.AsyncServer = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*") self.tunnel: Optional[RemoteServiceDiscoveryService] = None + self.fmf_queue: asyncio.Queue = asyncio.Queue + self.fmf_location: Optional[iCloudLocationData] = None class TunneldRunnerSio: @@ -325,10 +341,7 @@ class TunneldRunnerSio: return logger.info("Simulation worker: acquiring tunnel (udid=%s)", self.context.udid) - # tun = await asyncio.wait_for( - # get_tun(self.context.udid), - # timeout=TUNNEL_ACQUIRE_TIMEOUT_SECONDS, - # ) + tun = await get_tun(self.context.udid) logger.info("Simulation worker: tunnel acquired, connecting DVT provider") dvt_provider = DvtProvider(tun) @@ -369,6 +382,19 @@ class TunneldRunnerSio: self.context.simulation_active = False self.context.simulation_task = None + + + async def start_icloud_monitor() + """Start Apple iCloud Find My Monitor to retreive actual reported device location""" + monitor = FindMyMonitor(apple_id, apple_pw, self.context.fmf_queue) + monitor_task = asyncio.create_task(monitor.run_monitor(interval=30)) + while True: + updated_location = await self.context.fmf_queue.get() + if self.context.fmf_location !== updated_location: + self.context.fmf_location = update_location + self.context.sio.emit("fmf_update", updated_location, namespace="/",) + + async def pause_simulation_queue(): """Pauses asyncio.Queue playback""" self.context.queue_state = "PAUSED" @@ -390,23 +416,95 @@ class TunneldRunnerSio: except asyncio.QueueEmpty: break - async def end_simulation_queue() -> str: + def add_item(item_id, payload): + self.context.queue_data[item_id] = payload + self.context.queue_order.append(item_id) + + def remove_item(item_id): + if item_id in self.context.queue_order: + self.context.queue_order.remove(item_id) + + def get_item(item_id): + return self.context.queue_data[item_id] + + def update_item(item_id, **updates): + if item_id in self.context.queue_data: + self.context.queue_data[item_id].update(updates) + + def get_item_index(item_id): + return self.context.queue_order.index(item_id) + + def get_item_id_by_index(index): + return self.context.queue_order[index] + + def get_items_in_order(): + return [self.context.queue_data[i] for i in self.context.queue_order] + + + + async def end_simulation_queue() -> bool: """Ends asyncio.Queue playback and closes tunnel""" logger.info("End location simulation request from %s", sid) - if self.context.simulation_task is not None and not self.context.simulation_task.done(): - q = self.context.queue - if q.qsize() > 0: - await empty_simulation_queue() - while q.empty() and q.qsize() == 0: + try: + if self.context.test_mode: + q = self.context.queue + if q.qsize() > 0: + self.context.set_location_enabled = False + while not q.empty(): + try: + item = q.get_nowait() + q.task_done() + logger.info("Discarding item from queue: %s", item) + except asyncio.QueueEmpty: + break + await q.join() - with suppress(asyncio.CancelledError): - await self.context.simulation_task - if self.context.tunnel is not None: - async with DvtProvider(self.context.tunnel) as dvt, LocationSimulation(dvt) as locate_simulation: - await locate_simulation.clear() - self.context.simulation_active = False - self.context.queue_state = "SHUTDOWN" - return "ended" +# with suppress(asyncio.CancelledError): +# await self.context.simulation_task + self.context.simulation_active = False + self.context.queue_state = "SHUTDOWN" + return True + if not self.context.test_mode: + if self.context.simulation_task is not None and not self.context.simulation_task.done(): + q = self.context.queue + if q.qsize() > 0: + await empty_simulation_queue() + while q.empty() and q.qsize() == 0: + await q.join() + with suppress(asyncio.CancelledError): + await self.context.simulation_task + if self.context.tunnel is not None: + async with DvtProvider(self.context.tunnel) as dvt, LocationSimulation(dvt) as locate_simulation: + await locate_simulation.clear() + self.context.simulation_active = False + self.context.queue_state = "SHUTDOWN" + return True + except Exception as e: + logger.error(f"Error ending simulation queue: {e}") + return False + + + def get_status(): + data = { + "current_location": self.context.current_location, + "next_location": self.context.next_location, + "latitude": self.context.latitude, + "longitude": self.context.longitude, + "next_move": self.context.next_move, + "udid": self.context.udid, + "simulation_active": self.context.simulation_active, + "loc_id": self.context.loc_id, + "set_location_enable": self.context.set_location_enabled, + "queue_length": self.context.queue.qsize() if self.context.queue else 0, + "queue_state": self.context.queue_state, + "queue_order": self.context.queue_order, + "queue_data": self.context.queue_data, + "queue_status": self.context.queue_status.is_set() if self.context.queue_status else False, + "test_mode": self.context.test_mode, + "simulation_task": self.context.simulation_task.get_name() if self.context.simulation_task else None, + "tunnel": self.context.tunnel.service.address[0] if self.context.tunnel else None, + } + return data """ FastAPI HTTP Functions""" def generate_http_response( @@ -646,27 +744,22 @@ class TunneldRunnerSio: @self._app.get("/context-status") async def app_context_status() -> fastapi.Response: - data = { - "latitude": self.context.latitude, - "longitude": self.context.longitude, - "next_more": self.context.next_move, - "udid": self.context.udid, - "simulation_active": self.context.simulation_active, - "loc_id": self.context.loc_id, - "set_location_enable": self.context.set_location_enabled, - "queue_state": self.context.queue_state, - "queue_list": self.context.queue_list - } + data = get_status() return generate_http_response(data) """ Socket.IO Functions""" + async def sio_send_status(sid): + """ Send Current Status""" + await self.context.sio.emit("status", get_status(), namespace="/", to=sid) + """Socket.IO Connection Events""" @self.context.sio.event async def connect(sid, environ): """Client connection event handler.""" logger.info("Client connected: %s", sid) - return('%s connected' % sid) + await sio_send_status(sid) + return '%s connected' % sid @self.context.sio.event async def disconnect(sid): @@ -680,8 +773,9 @@ class TunneldRunnerSio: @self.context.sio.event async def message(sid, data): - logger.info("Received message from %s: %s", data, sid) - await self.context.sio.emit("message", f"Received message from {sid}: {data}", namespace="/") + 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 @@ -708,83 +802,86 @@ class TunneldRunnerSio: return { "command": command, "status": "error", "message": f"Invalid command: {command}" } @self.context.sio.event - async def simulate_control(sid, data): + 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) - match command: - case "add": - """ Add a location to the simulation queue""" - 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) - delay = 0 if delay is None else delay - 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) - await self.context.queue.put((loc_id, latitude, longitude, delay)) - if delay == 0: - start_time = datetime.now(timezone.utc).isoformat() - else: + try: + match command: + case "add": + """ Add a location to the simulation queue""" + 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) + delay = 0 if delay is None else delay + 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 + if self.context.queue_data: + accrued_delay = sum(item.get('delay', 0) for item in self.context.queue_data.values()) now_time = datetime.now(timezone.utc) - new_time = now_time + timedelta(seconds=delay) + new_time = now_time + timedelta(seconds=accrued_delay) + timedelta(seconds=delay) start_time = new_time.isoformat() - - location_item = { - loc_id: { + location_item = { "loc_id": loc_id, "latitude": latitude, "longitude": longitude, "delay": delay, "start": start_time } - } - ack = { - "command": command, - "status": "added", - "message": f"Location {loc_id} added to the queue", - "item": location_item - } - self.context.queue_list.append(location_item) - logger.info("Location %s added to the queue", loc_id) - return ack - else: - logger.warning("Invalid location data received from %s: %s", sid, data) - return {"command": command, "status": "error", "message": "Invalid location data"} - case "clear": - """ Clear the simulation queue""" - await empty_simulation_queue() - return {"command": command, "status": "cleared", "message": "Simulation cleared"} - case "pause": - """ Pause the simulation queue""" - await pause_simulation_queue() - return {"command": command, "status": "paused", "message": "Simulation paused"} - case "resume": - """ Resume the simulation queue""" - await resume_simulation_queue() - return {"command": command, "status": "resumed", "message": "Simulation resumed"} - case "end": - """ End the simulation queue""" - logger.info("End location simulation request from %s", sid) - end_task = asyncio.create_task(end_simulation_queue(), name="end-simulation-worker") - result = await end_task - return {"command": command, "status": result, "message": "Simulation ended"} - case "start": - """ Start the simulation queue""" - logger.info("Start location simulation request from %s", sid) - if self.context.simulation_task is None or self.context.simulation_task.done(): - self.context.simulation_active = True - self.context.simulation_task = asyncio.create_task( - start_simulation_queue(), - name="location-simulation-worker", - ) - return {"command": command, "status": "started", "message": "Simulation started"} - else: - return {"command": command, "status": "error", "message": "Simulation already running"} - case _: - logger.warning("Invalid command received from %s: %s", sid, command) - return {"status": "error", "message": "Invalid command"} + ack = { + "command": command, + "status": "added", + "message": f"Location {loc_id} added to the queue", + "item": location_item + } + await self.context.queue.put(loc_id) + add_item(loc_id, location_item) + logger.info("Location %s added to the queue", loc_id) + return ack + else: + logger.warning("Invalid location data received from %s: %s", sid, data) + return {"command": command, "status": "error", "message": "Invalid location data", "data": location_item} + case "clear": + """ Clear the simulation queue""" + await empty_simulation_queue() + return {"command": command, "status": "cleared", "message": "Simulation cleared"} + case "pause": + """ Pause the simulation queue""" + await pause_simulation_queue() + return {"command": command, "status": "paused", "message": "Simulation paused"} + case "resume": + """ Resume the simulation queue""" + await resume_simulation_queue() + return {"command": command, "status": "resumed", "message": "Simulation resumed"} + case "end": + """ End the simulation queue""" + logger.info("End location simulation request from %s", sid) + end_task = asyncio.create_task(end_simulation_queue(), name="end-simulation-worker") + result = await end_task + simstatus = not result + return {"command": command, "status": simstatus, "message": "Simulation ended"} + case "start": + """ Start the simulation queue""" + logger.info("Start location simulation request from %s", sid) + if self.context.simulation_task is None or self.context.simulation_task.done(): + self.context.simulation_active = True + self.context.queue_state = "RUNNING" + self.context.simulation_task = asyncio.create_task( + start_simulation_queue(), + name="location-simulation-worker", + ) + return {"command": command, "status": self.context.queue_state, "message": "Simulation started"} + else: + return {"command": command, "status": "error", "message": "Simulation already running"} + case _: + logger.warning("Invalid command received from %s: %s", sid, command) + return {"status": "error", "message": "Invalid command"} + finally: + await sio_send_status(sid) + """ Tunnel Control """ @self.context.sio.event @@ -798,7 +895,7 @@ class TunneldRunnerSio: try: self._tunneld_core.start() logger.info("Tunneld started successfully") - return {"status": "started", "message": "Tunneld started successfully"} + return {"status": "running", "message": "Tunneld started successfully"} except Exception as e: logger.error("Error starting tunneld: %s", e) return {"command": command, "status": "error", "message": f"Error starting tunneld: {e}"} @@ -855,9 +952,15 @@ class LocationSimulationQueue(LocationSimulation): continue if self.context.queue_state == "SHUTDOWN": break - loc_id, latitude, longitude, delay = await self.context.queue.get() - if (loc_id, latitude, longitude, delay) == (None, None, None, None): + loc_id = await self.context.queue.get() + if loc_id == None: break + location_item = self.context.queue_data.get(loc_id) + latitude = location_item.get("latitude") + longitude = location_item.get("longitude") + delay = location_item.get("delay") + delay = 0 if delay is None else delay + start_time = location_item.get("start_time") if self.context.set_location_enabled: if delay > 0 and not disable_sleep: if timing_randomness_range > 0: @@ -941,9 +1044,14 @@ class LocationSimulationTestQueue(LocationSimulationBase): await asyncio.sleep(0.1) if self.context.queue_state == "SHUTDOWN": break - loc_id, latitude, longitude, delay = await self.context.queue.get() - if (loc_id, latitude, longitude, delay) == (None, None, None, None): + loc_id = await self.context.queue.get() + if loc_id == None: break + location_item = self.context.queue_data.get(loc_id) + latitude = location_item.get("latitude") + longitude = location_item.get("longitude") + delay = location_item.get("delay") + start_time = location_item.get("start_time") if self.context.set_location_enabled: if delay > 0 and not disable_sleep: if timing_randomness_range > 0: @@ -969,6 +1077,7 @@ class LocationSimulationTestQueue(LocationSimulationBase): self.context.longitude = longitude self.context.loc_id = loc_id await self.context.sio.emit( + "simulation_status", { "status": self.context.simulation_active, diff --git a/server_recover.py b/server_recover.py new file mode 100644 index 0000000..1e66801 --- /dev/null +++ b/server_recover.py @@ -0,0 +1,1074 @@ +import asyncio + +from datetime import datetime, timezone, timedelta +import json +import uuid +import logging +import os +import signal +import traceback +import warnings +import random +from operator import truediv + +from pydantic import BaseModel, RootModel +import socketio +from contextlib import asynccontextmanager, suppress + +from typing import Optional, Dict + +from pymobiledevice3.services.dvt.instruments.location_simulation_base import LocationSimulationBase + +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 + +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 +from pymobiledevice3.tunneld.server import TunneldCore, TunnelTask + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info: + payload["exc_info"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=True) + + +handler = logging.StreamHandler() +handler.setFormatter(JsonFormatter()) +root_logger = logging.getLogger() +root_logger.handlers = [handler] +root_logger.setLevel(logging.INFO) +logger = logging.getLogger("ios-api") + +# 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 + + +class SimulationStatusData(BaseModel): + latitude: float + longitude: float + start: float + end: Optional[float] + next_move: Optional[float] + + +class SimulationStatus(BaseModel): + status: bool + data: Optional[SimulationStatusData] + + +class SimulationRequestData(BaseModel): + latitude: float + longitude: float + delay: int = 0 + start: Optional[str] = None + end: Optional[str] = None + + +class SimulationRequest(BaseModel): + status: bool + data: Optional[SimulationRequestData] + + +class SimulationRequestResponseData(BaseModel): + loc_id: str + latitude: float + longitude: float + delay: int = 0 + start: Optional[str] = None + end: Optional[str] = None + +class SimulationQueueList(BaseModel): + data: Optional[SimulationRequestResponseData] + + +class SimulationRequestResponse(BaseModel): + status: bool + data: Optional[SimulationRequestResponseData] + +class SimulationQueueDict(BaseModel): + location_id: Dict[str, SimulationRequestResponseData] + +class LocationSimulationState: + def __init__(self): + self.current_location: Optional[Dict[str, SimulationRequestResponseData]] = None + self.next_location: Optional[Dict[str, SimulationRequestResponseData]] = None + self.loc_id: Optional[str] = None + self.latitude: Optional[float] = None + self.longitude: Optional[float] = None + self.next_move: Optional[float] = None + self.udid: Optional[str] = None + self.simulation_active: bool = False + self.set_location_enabled: bool = True + self.queue: asyncio.Queue = asyncio.Queue() + self.queue_order: list[str] = [] + self.queue_data: Dict = {} + self.queue_status: Optional[asyncio.Event] = asyncio.Event() + self.queue_state: str = "STOPPED" + self.test_mode: bool = False + self.simulation_task: Optional[asyncio.Task] = None + self.sio: socketio.AsyncServer = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*") + self.tunnel: Optional[RemoteServiceDiscoveryService] = None + + +class TunneldRunnerSio: + """TunneldRunner orchestrate between the webserver and TunneldCore""" + + @classmethod + def create( + cls, + host: str, + port: int, + context: LocationSimulationState = LocationSimulationState(), + protocol: TunnelProtocol = TunnelProtocol.QUIC, + usb_monitor: bool = True, + wifi_monitor: bool = True, + usbmux_monitor: bool = True, + mobdev2_monitor: bool = True, + ) -> None: + cls( + host, + port, + protocol=protocol, + usb_monitor=usb_monitor, + wifi_monitor=wifi_monitor, + usbmux_monitor=usbmux_monitor, + mobdev2_monitor=mobdev2_monitor, + context=context, + )._run_app() + + def __init__( + self, + host: str, + port: int, + context: LocationSimulationState = LocationSimulationState(), + protocol: TunnelProtocol = TunnelProtocol.QUIC, + usb_monitor: bool = True, + wifi_monitor: bool = True, + usbmux_monitor: bool = True, + mobdev2_monitor: bool = True, + ): + @asynccontextmanager + async def lifespan(app: FastAPI): + self._tunneld_core.start() + yield + logger.info("Closing tunneld tasks...") + 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 + self._tunneld_api_address = ("127.0.0.1" if host in ("0.0.0.0", "::") else host, port) + self._app = FastAPI(title="iOS Device Management API", lifespan=lifespan, cors_allowed_origins="*") + self._asgi_app = socketio.ASGIApp(self.context.sio, self._app) + self._tunneld_core = TunneldCore( + protocol=protocol, + wifi_monitor=wifi_monitor, + usb_monitor=usb_monitor, + usbmux_monitor=usbmux_monitor, + mobdev2_monitor=mobdev2_monitor, + ) + + 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 cleanup_device_data(d): + mydict = {} + for key, value in d.items(): + if isinstance(value, dict): + cleanup_device_data(value) + elif isinstance(value, bytes): + mydict[key] = 'BYTE DATA' + else: + mydict[key] = value + return mydict + + async def device_reboot(delay): + """ Reboot the device""" + logger.info("Reboot iniated with delay %s") + await asyncio.sleep(delay) + os.system("shutdown -r now") + + async def device_shutdown(delay): + """ Shutdown the device""" + logger.info("Shutdown iniated with delay %s") + await asyncio.sleep(delay) + os.system("shutdown -h now") + + """ Queue Functions""" + async def start_simulation_queue(): + """ Start Simulation Queue Worker""" + logger.info("Starting location simulation worker...") + self.context.simulation_active = True + self.context.queue_state = "RUNNING" + self.context.queue_status.set() + try: + if self.context.test_mode: + logger.info("Simulation worker: test mode enabled") + with LocationSimulationTestQueue(self.context) as locate_simulation: + await locate_simulation.play_queue() + + if self.context.udid is None and not self.context.test_mode: + 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}, + namespace="/", + ) + finally: + self.context.simulation_active = False + self.context.simulation_task = None + + async def pause_simulation_queue(): + """Pauses asyncio.Queue playback""" + self.context.queue_state = "PAUSED" + + async def resume_simulation_queue(): + """Resumes asyncio.Queue playback""" + self.context.queue_state = "RUNNING" + + async def empty_simulation_queue(): + """Empties all items from an asyncio.Queue.""" + logger.info("Clearing location simulation queue...") + q = self.context.queue + self.context.set_location_enabled = False + while not q.empty(): + try: + item = q.get_nowait() + q.task_done() + logger.info("Discarding item from queue: %s", item) + except asyncio.QueueEmpty: + break + + def add_item(item_id, payload): + self.context.queue_data[item_id] = payload + self.context.queue_order.append(item_id) + + def remove_item(item_id): + if item_id in self.context.queue_order: + self.context.queue_order.remove(item_id) + + def get_item(item_id): + return self.context.queue_data[item_id] + + def update_item(item_id, **updates): + if item_id in self.context.queue_data: + self.context.queue_data[item_id].update(updates) + + def get_item_index(item_id): + return self.context.queue_order.index(item_id) + + def get_item_id_by_index(index): + return self.context.queue_order[index] + + def get_items_in_order(): + return [self.context.queue_data[i] for i in self.context.queue_order] + + + + async def end_simulation_queue() -> bool: + """Ends asyncio.Queue playback and closes tunnel""" + logger.info("End location simulation request from %s", sid) + try: + if self.context.test_mode: + q = self.context.queue + if q.qsize() > 0: + self.context.set_location_enabled = False + while not q.empty(): + try: + item = q.get_nowait() + q.task_done() + logger.info("Discarding item from queue: %s", item) + except asyncio.QueueEmpty: + break + + await q.join() +# with suppress(asyncio.CancelledError): +# await self.context.simulation_task + self.context.simulation_active = False + self.context.queue_state = "SHUTDOWN" + return True + if not self.context.test_mode: + if self.context.simulation_task is not None and not self.context.simulation_task.done(): + q = self.context.queue + if q.qsize() > 0: + await empty_simulation_queue() + while q.empty() and q.qsize() == 0: + await q.join() + with suppress(asyncio.CancelledError): + await self.context.simulation_task + if self.context.tunnel is not None: + async with DvtProvider(self.context.tunnel) as dvt, LocationSimulation(dvt) as locate_simulation: + await locate_simulation.clear() + self.context.simulation_active = False + logger.error(f"Error ending simulation queue: {e}") + return False + + + def get_status(): + data = { + "current_location": self.context.current_location, + "next_location": self.context.next_location, + "latitude": self.context.latitude, + "longitude": self.context.longitude, + "next_move": self.context.next_move, + "udid": self.context.udid, + "simulation_active": self.context.simulation_active, + "loc_id": self.context.loc_id, + "set_location_enable": self.context.set_location_enabled, + "queue": self.context.queue.qsize() if self.context.queue else 0, + "queue_state": self.context.queue_state, + "queue_order": self.context.queue_order, + "queue_data": self.context.queue_data, + "queue_status": self.context.queue_status.is_set() if self.context.queue_status else False, + "test_mode": self.context.test_mode, + "simulation_task": self.context.simulation_task.get_name() if self.context.simulation_task else None, + "tunnel": self.context.tunnel.service.address[0] if self.context.tunnel else None, + } + return 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(data)) + + """ Tunnel Functions""" + @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("/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) + + """Simulation Functions""" + @self._app.get("/start-simulation") + async def app_start_simulation() -> fastapi.Response: + logger.info("Simulation Start Requested ") + if self.context.simulation_task is None or self.context.simulation_task.done(): + self.context.simulation_active = True + self.context.simulation_task = asyncio.create_task( + start_simulation_queue(), + name="location-simulation-worker", + ) + data = {"status": "started", "message": "Simulation started"} + else: + data = {"status": "error", "message": "Simulation already running"} + return generate_http_response(data) + + @self._app.post("/add-location") + 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") + 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) + delay = 0 if delay is None else delay + 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) + await self.context.queue.put((loc_id, latitude, longitude, delay)) + if delay == 0: + start_time = datetime.now(timezone.utc).isoformat() + else: + now_time = datetime.now(timezone.utc) + new_time = now_time + timedelta(seconds=delay) + start_time = new_time.isoformat() + location_item = { + loc_id: { + "loc_id": loc_id, + "latitude": latitude, + "longitude": longitude, + "delay": delay, + "start": start_time + } + } + self.context.queue_list.append(location_item) + resp = { + "status": "added", + "message": f"Location {loc_id} added to the queue", + "item": location_item + } + else: + resp = {"status": "error", "message": "Invalid location data"} + return generate_http_response(resp) + + @self._app.get("/clear-queue") + async def app_clear_queue() -> fastapi.Response: + """ Clear the simulation queue""" + logger.info("Simulation Start Requested ") + await empty_simulation_queue() + data = {"status": "cleared", "message": "Simulation cleared"} + return generate_http_response(data) + + @self._app.get("/pause-queue") + async def app_pause_queue() -> fastapi.Response: + """ Pause the simulation queue""" + await pause_simulation_queue() + data = {"status": "paused", "message": "Simulation paused"} + return generate_http_response(data) + + @self._app.get("/resume-queue") + async def app_resume_queue() -> fastapi.Response: + """ Resume the simulation queue""" + await resume_simulation_queue() + data = {"status": "resumed", "message": "Simulation resumed"} + return generate_http_response(data) + + @self._app.get("/end-simulation") + async def app_end_simulation() -> fastapi.Response: + """ End the simulation queue""" + logger.info("End location simulation request") + end_task = asyncio.create_task(end_simulation_queue(), name="end-simulation-worker") + result = await end_task + data = {"status": result, "message": "Simulation ended"} + return generate_http_response(data) + + """Status 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("/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] = iterate_multidim(lockdown.all_values) + except Exception as e: + logger.error(f"Failed to create lockdown session for device {active_tunnel.udid}: {e}") + continue + return tunnels + + @self._app.get("/hello") + async def hello() -> fastapi.Response: + data = {"message": "Hello, I'm alive"} + return generate_http_response(data) + + @self._app.get("/context-status") + async def app_context_status() -> fastapi.Response: + data = get_status() + return generate_http_response(data) + + """ Socket.IO Functions""" + + async def sio_send_status(sid): + """ Send Current Status""" + await self.context.sio.emit("status", get_status(), namespace="/", to=sid) + + """Socket.IO Connection Events""" + @self.context.sio.event + async def connect(sid, environ): + """Client connection event handler.""" + 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.""" + logger.info("Client disconnected: %s", sid) + + """ Socket.IO Request Events """ + @self.context.sio.event + async def request_update(sid, data): + logger.info("Update request from %s", sid) + + @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": "shutdown", "status": "success", "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": "reboot", "status": "success", "message": f"Device reboot initiated with {delay} seconds delay" } + + case _: + return { "command": command, "status": "error", "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 "add": + """ Add a location to the simulation queue""" + 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) + delay = 0 if delay is None else delay + 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 + if self.context.queue_data: + accrued_delay = sum(item.get('delay', 0) for item in self.context.queue_data.values()) + now_time = datetime.now(timezone.utc) + new_time = now_time + timedelta(seconds=accrued_delay) + timedelta(seconds=delay) + start_time = new_time.isoformat() + location_item = { + "loc_id": loc_id, + "latitude": latitude, + "longitude": longitude, + "delay": delay, + "start": start_time + } + ack = { + "command": command, + "status": "added", + "message": f"Location {loc_id} added to the queue", + "item": location_item + } + await self.context.queue.put(loc_id) + add_item(loc_id, location_item) + logger.info("Location %s added to the queue", loc_id) + return ack + else: + logger.warning("Invalid location data received from %s: %s", sid, data) + return {"command": command, "status": "error", "message": "Invalid location data", "data": location_item} + case "clear": + """ Clear the simulation queue""" + await empty_simulation_queue() + return {"command": command, "status": "cleared", "message": "Simulation cleared"} + case "pause": + """ Pause the simulation queue""" + await pause_simulation_queue() + return {"command": command, "status": "paused", "message": "Simulation paused"} + case "resume": + """ Resume the simulation queue""" + await resume_simulation_queue() + return {"command": command, "status": "resumed", "message": "Simulation resumed"} + case "end": + """ End the simulation queue""" + logger.info("End location simulation request from %s", sid) + end_task = asyncio.create_task(end_simulation_queue(), name="end-simulation-worker") + result = await end_task + simstatus = not result + return {"command": command, "status": simstatus, "message": "Simulation ended"} + case "start": + """ Start the simulation queue""" + logger.info("Start location simulation request from %s", sid) + if self.context.simulation_task is None or self.context.simulation_task.done(): + self.context.simulation_active = True + self.context.queue_state = "RUNNING" + self.context.simulation_task = asyncio.create_task( + start_simulation_queue(), + name="location-simulation-worker", + ) + return {"command": command, "status": self.context.queue_state, "message": "Simulation started"} + else: + return {"command": command, "status": "error", "message": "Simulation already running"} + case _: + logger.warning("Invalid command received from %s: %s", sid, command) + return {"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 {"status": "running", "message": "Tunneld started successfully"} + except Exception as e: + logger.error("Error starting tunneld: %s", e) + return {"command": command, "status": "error", "message": f"Error starting tunneld: {e}"} + + case "shutdown": + """Shutdown Tunneld""" + logger.info("Shutdown tunneld request from %s: %s", sid, data) + try: + os.kill(os.getpid(), signal.SIGINT) + return {"command": command, "status": "Success", "message": "Server shutting down..."} + except Exception as e: + logger.error("Error shutting down tunneld: %s", e) + return {"command": 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, "status": "Success", "message": "Cleared tunnels..."} + except Exception as e: + logger.error("Error clearing tunnels: %s", e) + return {"command": 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, "status": "Success", "udid": udid, "message": f"tunnel {udid} Canceled ..."} + except Exception as e: + logger.error("Error canceling tunnel: %s", e) + return {"command": command, "status": "error", "message": f"Error canceling tunnel: {e}"} + + case _: + return {"command": command, "status": "error", "message": f"Unknown operation: {command}"} + + + 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): + super().__init__(dvt) + self.context = context + + async def play_queue(self, disable_sleep: bool = False, timing_randomness_range: int = 0) -> None: + while True: + if self.context.queue_state == "PAUSED": + await asyncio.sleep(0.1) + continue + if self.context.queue_state == "SHUTDOWN": + break + loc_id = await self.context.queue.get() + if loc_id == None: + break + location_item = self.context.queue_data.get(loc_id) + latitude = location_item.get("latitude") + longitude = location_item.get("longitude") + delay = location_item.get("delay") + delay = 0 if delay is None else delay + start_time = location_item.get("start_time") + if self.context.set_location_enabled: + if delay > 0 and not disable_sleep: + if timing_randomness_range > 0: + delay = delay + random.uniform( + -timing_randomness_range, timing_randomness_range + ) + for i in range(delay, 0, -1): + self.context.next_move = i + await self.context.sio.emit( + "simulation_status", + { + "status": self.context.simulation_active, + "loc_id": self.context.loc_id, + "latitude": self.context.latitude, + "longitude": self.context.longitude, + "next_move": i + }, + namespace="/", + ) + await asyncio.sleep(1) + await self.set(latitude, longitude) + self.context.latitude = latitude + self.context.longitude = longitude + await self.context.sio.emit( + "simulation_status", + { + "status": self.context.simulation_active, + "loc_id": self.context.loc_id, + "latitude": self.context.latitude, + "longitude": self.context.longitude, + "next_move": None, + }, + namespace="/", + ) + logger.info( + "Set simulated location to %s, %s after %ss delay", + latitude, + longitude, + delay, + ) + self.context.queue.task_done() + + +class LocationSimulationTestQueue(LocationSimulationBase): + def __init__(self, context: LocationSimulationState): + super().__init__() + self.context = context + + def __enter__(self): + return self + + def __exit__(self): + return self + + async def set(self, latitude: float, longitude: float) -> None: + await asyncio.sleep(0.1) + logger.info("Simulated location set to %s, %s", latitude, longitude) + + async def clear(self) -> None: + q = self.context.queue + if self.context.simulation_task is not None and not self.context.simulation_task.done(): + if q.qsize() > 0: + self.context.set_location_enabled = False + while not q.empty(): + try: + item = q.get_nowait() + q.task_done() + logger.info("Discarding item from queue: %s", item) + except asyncio.QueueEmpty: + break + while q.empty() and q.qsize() == 0: + await q.join() + with suppress(asyncio.CancelledError): + await self.context.simulation_task + self.context.simulation_active = False + self.context.queue_state = "SHUTDOWN" + + async def play_queue(self, disable_sleep: bool = False, timing_randomness_range: int = 0) -> None: + while True: + while self.context.queue_state == "PAUSED": + await asyncio.sleep(0.1) + if self.context.queue_state == "SHUTDOWN": + break + loc_id = await self.context.queue.get() + if loc_id == None: + break + location_item = self.context.queue_data.get(loc_id) + latitude = location_item.get("latitude") + longitude = location_item.get("longitude") + delay = location_item.get("delay") + start_time = location_item.get("start_time") + if self.context.set_location_enabled: + if delay > 0 and not disable_sleep: + if timing_randomness_range > 0: + delay = delay + random.uniform( + -timing_randomness_range, timing_randomness_range + ) + for i in range(delay, 0, -1): + self.context.next_move = i + await self.context.sio.emit( + "simulation_status", + { + "status": self.context.simulation_active, + "loc_id": self.context.loc_id, + "latitude": self.context.latitude, + "longitude": self.context.longitude, + "next_move": i + }, + namespace="/", + ) + await asyncio.sleep(1) + ) + await asyncio.sleep(1) + await self.set(latitude, longitude) + self.context.latitude = latitude + self.context.longitude = longitude + self.context.loc_id = loc_id + await self.context.sio.emit( + + "simulation_status", + { + "status": self.context.simulation_active, + "loc_id": self.context.loc_id, + "latitude": self.context.latitude, + "longitude": self.context.longitude, + "next_move": None, + }, + namespace="/", + ) + logger.info( + "Set simulated location to %s, %s after %ss delay", + latitude, + longitude, + delay, + ) + self.context.queue.task_done()