extensive changes
This commit is contained in:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/pymd3_vue_location_sim/__init__.py
Normal file
0
src/pymd3_vue_location_sim/__init__.py
Normal file
44
src/pymd3_vue_location_sim/database.py
Normal file
44
src/pymd3_vue_location_sim/database.py
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
from typing import List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from .db_models import Base, Location, Route, Waypoint
|
||||
|
||||
|
||||
DATABASE_URL = "sqlite+aiosqlite:///.locations.db"
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
async_session_local = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def init_db() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def create_location(name: str, address: str, latitude: float, longitude: float, is_favorite: bool = False):
|
||||
async with async_session_local() as session:
|
||||
new_location = Location(name=name, address=address, latitude=latitude, longitude=longitude, is_favorite=is_favorite)
|
||||
session.add(new_location)
|
||||
await session.commit()
|
||||
return new_location
|
||||
|
||||
async def get_locations():
|
||||
async with async_session_local() as session:
|
||||
result = await session.execute(select(Location))
|
||||
return result.scalars().all()
|
||||
|
||||
async def create_route(name: str, origin_id: int, destination_id: int, waypoints_data: List[dict]):
|
||||
async with async_session_local() as session:
|
||||
new_route = Route(name=name, origin_id=origin_id, destination_id=destination_id)
|
||||
for wp in waypoints_data:
|
||||
new_route.waypoints.append(Waypoint(order=wp['order'], description=wp['description']))
|
||||
session.add(new_route)
|
||||
await session.commit()
|
||||
return new_route
|
||||
|
||||
async def get_routes():
|
||||
async with async_session() as session:
|
||||
# Use joinedload for efficient loading of relationships
|
||||
from sqlalchemy.orm import joinedload
|
||||
result = await session.execute(
|
||||
select(Route).options(joinedload(Route.waypoints))
|
||||
)
|
||||
return result.scalars().unique().all()
|
||||
42
src/pymd3_vue_location_sim/db_models.py
Normal file
42
src/pymd3_vue_location_sim/db_models.py
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from typing import List
|
||||
|
||||
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
pass
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
address: Mapped[str] = mapped_column(String(255))
|
||||
latitude: Mapped[float]
|
||||
longitude: Mapped[float]
|
||||
is_favorite: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
routes: Mapped[List["Route"]] = relationship(back_populates="destination", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Route(Base):
|
||||
__tablename__ = "routes"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
origin_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
|
||||
destination_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
|
||||
destination: Mapped["Location"] = relationship(back_populates="routes")
|
||||
|
||||
waypoints: Mapped[List["Waypoint"]] = relationship(back_populates="route", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
|
||||
class Waypoint(Base):
|
||||
__tablename__ = "waypoint"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order: Mapped[int]
|
||||
description: Mapped[str]
|
||||
route_id: Mapped[int] = mapped_column(ForeignKey("route.id"))
|
||||
route: Mapped["Route"] = relationship(back_populates="waypoints")
|
||||
|
||||
334
src/pymd3_vue_location_sim/icloud_monitor.py
Normal file
334
src/pymd3_vue_location_sim/icloud_monitor.py
Normal file
@@ -0,0 +1,334 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
from pyicloud import PyiCloudService
|
||||
from pyicloud.exceptions import (
|
||||
PyiCloud2FARequiredException,
|
||||
PyiCloud2SARequiredException,
|
||||
PyiCloudAuthRequiredException,
|
||||
PyiCloudFailedLoginException,
|
||||
PyiCloudNoStoredPasswordAvailableException,
|
||||
PyiCloudPasswordException,
|
||||
)
|
||||
|
||||
from .models import iCloudReturnData
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger("ios-api")
|
||||
AUTH_EXCEPTIONS = (
|
||||
PyiCloudAuthRequiredException,
|
||||
PyiCloudFailedLoginException,
|
||||
PyiCloudPasswordException,
|
||||
PyiCloud2FARequiredException,
|
||||
PyiCloud2SARequiredException,
|
||||
PyiCloudNoStoredPasswordAvailableException,
|
||||
)
|
||||
|
||||
class FindMyMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
queue: asyncio.Queue,
|
||||
token_file: str = "icloud_token.txt",
|
||||
sio=None,
|
||||
get_client_sids: Callable[[], list[str]] | None = None,
|
||||
code_timeout_seconds: int = 180,
|
||||
):
|
||||
self.username = os.getenv("APPLE_ID")
|
||||
self.password = os.getenv("APPLE_PW")
|
||||
self.token_file = token_file
|
||||
self.selected_device_id = os.getenv("SELECTED_DEVICE_ID")
|
||||
self.selected_device_name = os.getenv("SELECTED_DEVICE_NAME")
|
||||
self.selected_device = None
|
||||
self.queue = queue
|
||||
self.api = None
|
||||
self.device = None
|
||||
self.running = True
|
||||
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("ICLOUD_AUTH_INIT_TIMEOUT_SECONDS", "0")
|
||||
)
|
||||
self._logged_candidates = False
|
||||
self._no_location_streak = 0
|
||||
self._auth_error_streak = 0
|
||||
|
||||
async def _request_code_from_vue(self, prompt: str) -> str | None:
|
||||
if self.sio is None or self.get_client_sids is None:
|
||||
logger.warning("2FA request skipped: Socket.IO context not configured")
|
||||
return None
|
||||
sids = self.get_client_sids()
|
||||
if not sids:
|
||||
logger.warning("2FA request skipped: no connected Socket.IO clients")
|
||||
return None
|
||||
|
||||
payload = {"prompt": prompt, "digits": 6}
|
||||
logger.info("Emitting icloud_2fa_request to %s connected client(s)", len(sids))
|
||||
for sid in sids:
|
||||
try:
|
||||
# Ask one connected UI client for the code and wait for ACK response.
|
||||
response = await self.sio.call(
|
||||
"icloud_2fa_request",
|
||||
payload,
|
||||
to=sid,
|
||||
namespace="/",
|
||||
timeout=self.code_timeout_seconds,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to retrieve 2FA code from sid=%s", sid)
|
||||
continue
|
||||
|
||||
code = response.get("code") if isinstance(response, dict) else response
|
||||
code_str = str(code).strip() if code is not None else ""
|
||||
if len(code_str) == 6 and code_str.isdigit():
|
||||
return code_str
|
||||
logger.warning("Invalid 2FA code payload from sid=%s", sid)
|
||||
return None
|
||||
|
||||
async def authenticate(self):
|
||||
"""Authenticates with iCloud, handling 2FA and token storage."""
|
||||
if not self.username:
|
||||
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:
|
||||
logger.warning(
|
||||
"No stored iCloud session and APPLE_PW is not configured; skipping iCloud monitor authentication"
|
||||
)
|
||||
return False
|
||||
|
||||
if os.path.exists(self.token_file):
|
||||
print("Loading stored session...")
|
||||
try:
|
||||
init_task = asyncio.to_thread(
|
||||
PyiCloudService, self.username, cookie_directory="./cookies"
|
||||
)
|
||||
if self.auth_init_timeout_seconds > 0:
|
||||
self.api = await asyncio.wait_for(
|
||||
init_task, timeout=self.auth_init_timeout_seconds
|
||||
)
|
||||
else:
|
||||
self.api = await init_task
|
||||
except Exception as e:
|
||||
logger.exception("Failed to initialize iCloud session from cookies: %s", e)
|
||||
return False
|
||||
else:
|
||||
print("No stored session. Authenticating...")
|
||||
try:
|
||||
init_task = asyncio.to_thread(
|
||||
PyiCloudService,
|
||||
self.username,
|
||||
self.password,
|
||||
cookie_directory="./cookies",
|
||||
)
|
||||
if self.auth_init_timeout_seconds > 0:
|
||||
self.api = await asyncio.wait_for(
|
||||
init_task, timeout=self.auth_init_timeout_seconds
|
||||
)
|
||||
else:
|
||||
self.api = await init_task
|
||||
except Exception as e:
|
||||
logger.exception("Failed to initialize iCloud session with credentials: %s", e)
|
||||
return False
|
||||
|
||||
if self.api.requires_2fa:
|
||||
print("Two-factor authentication required.")
|
||||
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(
|
||||
await asyncio.to_thread(
|
||||
click.prompt,
|
||||
"Please enter the 6-digit code sent to your trusted device",
|
||||
type=int,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"2FA required but no interactive terminal or Vue responder is available; deferring authentication"
|
||||
)
|
||||
return False
|
||||
# Verify the code
|
||||
result = await asyncio.to_thread(self.api.validate_2fa_code, code)
|
||||
print(f"2FA validation result: {result}")
|
||||
|
||||
if not result:
|
||||
print("Failed to verify 2FA code")
|
||||
return False
|
||||
|
||||
# Trust the session
|
||||
await asyncio.to_thread(self.api.trust_session)
|
||||
|
||||
if self.api.requires_2sa:
|
||||
import textwrap
|
||||
print(textwrap.dedent("""
|
||||
Two-step authentication required.
|
||||
Please select a device to receive a SMS verification code:
|
||||
"""))
|
||||
# List available devices for 2SA
|
||||
for i, device in enumerate(self.api.trusted_devices):
|
||||
print(
|
||||
f" {i + 1}: {device.get('deviceName', 'Unknown device')} ({device.get('phoneNumber', 'Unknown number')})")
|
||||
|
||||
# Prompt the user for their choice
|
||||
if not (sys.stdin and sys.stdin.isatty()):
|
||||
logger.warning(
|
||||
"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 = self.api.trusted_devices[device_index]
|
||||
if not await asyncio.to_thread(self.api.send_verification_code, device):
|
||||
print("Failed to send verification code")
|
||||
return False
|
||||
|
||||
# Prompt the user to enter the verification code they received
|
||||
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):
|
||||
print("Failed to verify verification code")
|
||||
return False
|
||||
|
||||
print("Successfully authenticated.")
|
||||
return True
|
||||
|
||||
async def get_location(self):
|
||||
"""Fetches the latest latitude and longitude."""
|
||||
if not self.api:
|
||||
await self.authenticate()
|
||||
|
||||
# pyicloud 2.4.1 refresh lives on the FindMy manager, not PyiCloudService.
|
||||
devices_manager = await asyncio.to_thread(lambda: self.api.devices)
|
||||
await asyncio.to_thread(devices_manager.refresh, True)
|
||||
|
||||
# One-time diagnostics to help verify exact device selection among duplicates.
|
||||
if not self._logged_candidates:
|
||||
for d in devices_manager:
|
||||
status = d.status()
|
||||
logger.debug(
|
||||
"iCloud candidate id=%s name=%s model=%s deviceStatus=%s has_location=%s features=%s",
|
||||
d.data.get("id"),
|
||||
d.name,
|
||||
d.model_name,
|
||||
status.get("deviceStatus"),
|
||||
d.data.get("location") is not None,
|
||||
d.data.get("features"),
|
||||
)
|
||||
self._logged_candidates = True
|
||||
|
||||
# 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:
|
||||
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:
|
||||
self.selected_device = device
|
||||
break
|
||||
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
|
||||
|
||||
if self.selected_device:
|
||||
location = self.selected_device.location
|
||||
status = self.selected_device.status()
|
||||
logger.info(
|
||||
"iCloud device=%s location_available=%s status=%s",
|
||||
self.selected_device.name,
|
||||
self.selected_device.location_available,
|
||||
status,
|
||||
)
|
||||
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']
|
||||
}
|
||||
response = iCloudReturnData(**data)
|
||||
return response
|
||||
logger.info("Location payload is None for device=%s", self.selected_device.name)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"No iCloud device matched SELECTED_DEVICE_ID='%s' or SELECTED_DEVICE_NAME='%s'.",
|
||||
self.selected_device_id,
|
||||
self.selected_device_name,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
def _no_location_backoff_seconds(self, base_interval: int) -> int:
|
||||
schedule = [15, 30, 60, 120, 300]
|
||||
idx = min(self._no_location_streak - 1, len(schedule) - 1)
|
||||
return max(base_interval, schedule[idx])
|
||||
|
||||
def _auth_backoff_seconds(self, base_interval: int) -> int:
|
||||
schedule = [15, 30, 60, 120, 300]
|
||||
idx = min(self._auth_error_streak - 1, len(schedule) - 1)
|
||||
return max(base_interval, schedule[idx])
|
||||
|
||||
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:
|
||||
sleep_seconds = interval
|
||||
try:
|
||||
device_data = await self.get_location()
|
||||
if device_data is not None:
|
||||
self._no_location_streak = 0
|
||||
self._auth_error_streak = 0
|
||||
print(f"{device_data.timeStamp} - Location: {device_data.latitude}, {device_data.longitude}")
|
||||
await self.queue.put(device_data)
|
||||
else:
|
||||
self._no_location_streak += 1
|
||||
sleep_seconds = self._no_location_backoff_seconds(interval)
|
||||
logger.info(
|
||||
"No iCloud location found for device id='%s' name='%s' streak=%s next_retry=%ss",
|
||||
self.selected_device_id,
|
||||
self.selected_device_name,
|
||||
self._no_location_streak,
|
||||
sleep_seconds,
|
||||
)
|
||||
except AUTH_EXCEPTIONS as e:
|
||||
self._auth_error_streak += 1
|
||||
sleep_seconds = self._auth_backoff_seconds(interval)
|
||||
logger.warning(
|
||||
"iCloud auth error (%s). Re-authenticating; streak=%s next_retry=%ss",
|
||||
type(e).__name__,
|
||||
self._auth_error_streak,
|
||||
sleep_seconds,
|
||||
)
|
||||
try:
|
||||
await self.authenticate()
|
||||
except Exception:
|
||||
logger.exception("iCloud re-authentication failed")
|
||||
except Exception as e:
|
||||
logger.exception("iCloud monitor loop error: %s", e)
|
||||
sleep_seconds = max(interval, 30)
|
||||
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
18
src/pymd3_vue_location_sim/json_formatter.py
Normal file
18
src/pymd3_vue_location_sim/json_formatter.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import logging
|
||||
import json
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
root_logger = logging.getLogger()
|
||||
logger = logging.getLogger("ios-api")
|
||||
|
||||
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)
|
||||
213
src/pymd3_vue_location_sim/locationsimulation.py
Normal file
213
src/pymd3_vue_location_sim/locationsimulation.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from pymobiledevice3.services.dvt.instruments.location_simulation_base import (
|
||||
LocationSimulationBase,
|
||||
)
|
||||
from pymobiledevice3.services.dvt.instruments.location_simulation import (
|
||||
LocationSimulation,
|
||||
)
|
||||
|
||||
|
||||
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 is None:
|
||||
self.context.queue.task_done()
|
||||
break
|
||||
location_item = self.context.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.queue.task_done()
|
||||
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_location_item = self.context.queue_data.get(self.context.loc_id)
|
||||
current_latitude = (
|
||||
current_location_item.get("latitude")
|
||||
if isinstance(current_location_item, dict)
|
||||
else self.context.latitude
|
||||
)
|
||||
current_longitude = (
|
||||
current_location_item.get("longitude")
|
||||
if isinstance(current_location_item, dict)
|
||||
else self.context.longitude
|
||||
)
|
||||
current_start = (
|
||||
current_location_item.get("start")
|
||||
if isinstance(current_location_item, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
if self.context.set_location_enabled:
|
||||
if new_delay > 0 and not disable_sleep:
|
||||
countdown_delay = int(round(float(new_delay)))
|
||||
if timing_randomness_range > 0:
|
||||
new_delay = new_delay + random.uniform(
|
||||
-timing_randomness_range, timing_randomness_range
|
||||
)
|
||||
countdown_delay = int(round(float(new_delay)))
|
||||
for i in range(max(0, countdown_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": current_latitude,
|
||||
"longitude": current_longitude,
|
||||
"start": current_start,
|
||||
"next_move": i,
|
||||
},
|
||||
namespace="/",
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
self.context.queue_data[loc_id]["start"] = self.context.queue_data[self.context.loc_id]["end"] = datetime.now(timezone.utc).isoformat()
|
||||
await self.set(new_latitude, new_longitude)
|
||||
self.context.loc_id = loc_id
|
||||
self.context.latitude = new_latitude
|
||||
self.context.longitude = new_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,
|
||||
"start": new_start,
|
||||
"next_move": None,
|
||||
},
|
||||
namespace="/",
|
||||
)
|
||||
logger.info(
|
||||
"Set simulated location to %s, %s after %ss delay",
|
||||
new_latitude,
|
||||
new_longitude,
|
||||
new_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
|
||||
self.context.set_location_enabled = False
|
||||
self.context.queue_state = "SHUTDOWN"
|
||||
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.put(None)
|
||||
|
||||
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:
|
||||
self.context.simulation_task.cancel()
|
||||
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:
|
||||
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 is None:
|
||||
self.context.queue.task_done()
|
||||
break
|
||||
location_item = self.context.queue_data.get(loc_id)
|
||||
if location_item is None:
|
||||
logger.warning(
|
||||
"Test simulation queue item missing for loc_id=%s; skipping stale entry",
|
||||
loc_id,
|
||||
)
|
||||
self.context.queue.task_done()
|
||||
continue
|
||||
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")
|
||||
if self.context.set_location_enabled:
|
||||
if delay > 0 and not disable_sleep:
|
||||
countdown_delay = int(round(float(delay)))
|
||||
if timing_randomness_range > 0:
|
||||
delay = delay + random.uniform(
|
||||
-timing_randomness_range, timing_randomness_range
|
||||
)
|
||||
countdown_delay = int(round(float(delay)))
|
||||
for i in range(max(0, countdown_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()
|
||||
64
src/pymd3_vue_location_sim/models.py
Normal file
64
src/pymd3_vue_location_sim/models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from typing import Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
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 iCloudLocationData(BaseModel):
|
||||
latitude: float
|
||||
longitude: float
|
||||
timestamp: str
|
||||
|
||||
class iCloudReturnData(BaseModel):
|
||||
latitude: float
|
||||
longitude: float
|
||||
timeStamp: int
|
||||
altitude: float
|
||||
horizontalAccuracy: float
|
||||
verticalAccuracy: float
|
||||
batteryLevel: float
|
||||
deviceDisplayName: str
|
||||
deviceStatus: int
|
||||
name: str
|
||||
379
src/pymd3_vue_location_sim/routes/api.py
Normal file
379
src/pymd3_vue_location_sim/routes/api.py
Normal file
@@ -0,0 +1,379 @@
|
||||
""" 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("/restart-tunneld")
|
||||
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)
|
||||
|
||||
|
||||
@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()
|
||||
):
|
||||
await start_icloud_monitor()
|
||||
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.get("/start-icloud-monitor")
|
||||
async def app_start_icloud_monitor() -> fastapi.Response:
|
||||
await start_icloud_monitor()
|
||||
data = {
|
||||
"status": "started",
|
||||
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||
"icloud_monitor_running": is_icloud_monitor_running(),
|
||||
}
|
||||
return generate_http_response(data)
|
||||
|
||||
@self._app.get("/stop-icloud-monitor")
|
||||
async def app_stop_icloud_monitor() -> fastapi.Response:
|
||||
await end_icloud_monitor()
|
||||
data = {
|
||||
"status": "stopped",
|
||||
"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 = {
|
||||
"status": "ok",
|
||||
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||
"icloud_monitor_running": is_icloud_monitor_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)
|
||||
)
|
||||
try:
|
||||
delay = parse_delay_seconds(delay)
|
||||
except ValueError as e:
|
||||
return generate_http_response(
|
||||
{"status": "error", "message": str(e)},
|
||||
status_code=400,
|
||||
)
|
||||
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(
|
||||
parse_delay_seconds(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,
|
||||
"status": "queued",
|
||||
}
|
||||
resp = {
|
||||
"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)
|
||||
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("/device-name")
|
||||
async def rsd_info():
|
||||
"""Get rsd information"""
|
||||
device_name = await get_device_name()
|
||||
return generate_http_response(device_name)
|
||||
|
||||
@self._app.get("/rsd-info")
|
||||
async def 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("/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)
|
||||
425
src/pymd3_vue_location_sim/routes/socketio.py
Normal file
425
src/pymd3_vue_location_sim/routes/socketio.py
Normal file
@@ -0,0 +1,425 @@
|
||||
""" 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": "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)
|
||||
)
|
||||
try:
|
||||
delay = parse_delay_seconds(delay)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"command": command,
|
||||
"status": "error",
|
||||
"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
|
||||
if self.context.queue_data:
|
||||
accrued_delay = sum(
|
||||
parse_delay_seconds(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()
|
||||
coords = f"{latitude}, {longitude}"
|
||||
rev_geocode = self.context.reverse_geocode(coords)
|
||||
if rev_geocode:
|
||||
address = rev_geocode.address
|
||||
else:
|
||||
address = f"{latitude}, {longitude}"
|
||||
|
||||
location_item = {
|
||||
"loc_id": loc_id,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"delay": delay,
|
||||
"start": start_time,
|
||||
"address": address,
|
||||
}
|
||||
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()
|
||||
):
|
||||
await start_icloud_monitor()
|
||||
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)
|
||||
|
||||
|
||||
@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,
|
||||
"status": "running",
|
||||
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||
"icloud_monitor_running": is_icloud_monitor_running(),
|
||||
}
|
||||
case "stop":
|
||||
await end_icloud_monitor()
|
||||
return {
|
||||
"command": command,
|
||||
"status": "stopped",
|
||||
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||
"icloud_monitor_running": is_icloud_monitor_running(),
|
||||
}
|
||||
case "status":
|
||||
return {
|
||||
"command": command,
|
||||
"status": "ok",
|
||||
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||
"icloud_monitor_running": is_icloud_monitor_running(),
|
||||
}
|
||||
case _:
|
||||
return {
|
||||
"command": 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 {
|
||||
"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 "start-watcher":
|
||||
""" Start Tunneld Watcher """
|
||||
logger.info("Start tunneld watcher request from %s: %s", sid, data)
|
||||
await start_tunnel_watcher()
|
||||
return {
|
||||
"status": "running",
|
||||
"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 {
|
||||
"status": "stopped",
|
||||
"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": 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}",
|
||||
}
|
||||
1835
src/pymd3_vue_location_sim/server.py
Normal file
1835
src/pymd3_vue_location_sim/server.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user