milestones
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
__pycache__/*
|
__pycache__/*
|
||||||
*.pyc
|
*.pyc
|
||||||
cookies/*
|
cookies/*
|
||||||
|
/cookies/*
|
||||||
|
geocache.db
|
||||||
|
/.uv-cache/
|
||||||
|
/AI Chats/
|
||||||
|
|||||||
Generated
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="PyToolsState">
|
||||||
|
<option name="tools">
|
||||||
|
<map>
|
||||||
|
<entry key="black">
|
||||||
|
<value>
|
||||||
|
<ToolEntry />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="pyrefly">
|
||||||
|
<value>
|
||||||
|
<ToolEntry />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="pyright">
|
||||||
|
<value>
|
||||||
|
<ToolEntry />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="ruff">
|
||||||
|
<value>
|
||||||
|
<ToolEntry />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="ty">
|
||||||
|
<value>
|
||||||
|
<ToolEntry />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+3
@@ -4,6 +4,9 @@
|
|||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/cookies" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.uv-cache" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/AI Chats" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
<orderEntry type="jdk" jdkName="uv (pymd3_vue_location_sim)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="uv (pymd3_vue_location_sim)" jdkType="Python SDK" />
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"sqlalchemy-orm>=1.2.10",
|
"sqlalchemy-orm>=1.2.10",
|
||||||
"typer>=0.24.1",
|
"typer>=0.24.1",
|
||||||
"typing==3.10.0.0",
|
"typing==3.10.0.0",
|
||||||
|
"uptime>=3.0.1",
|
||||||
"uvicorn==0.41.0",
|
"uvicorn==0.41.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,27 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
from geopy.geocoders import Nominatim
|
from geopy.geocoders import Nominatim
|
||||||
|
from geopy.distance import geodesic
|
||||||
from geopy.adapters import AioHTTPAdapter
|
from geopy.adapters import AioHTTPAdapter
|
||||||
from geopy.extra.rate_limiter import AsyncRateLimiter
|
from geopy.extra.rate_limiter import AsyncRateLimiter
|
||||||
|
|
||||||
logger = logging.getLogger("ios-api")
|
from .models import Favorite
|
||||||
CACHE_LOOKUP_SQL = "SELECT address, favorite FROM location_cache WHERE lat_lon = ?"
|
|
||||||
CACHE_UPSERT_SQL = """
|
|
||||||
INSERT INTO location_cache (lat_lon, address)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address
|
|
||||||
"""
|
|
||||||
FAVORITE_LOOKUP_SQL = "SELECT favorite FROM location_cache WHERE favorite IS NOT NULL"
|
|
||||||
FAVORITE_UPSERT_SQL = """
|
|
||||||
INSERT INTO location_cache (lat_lon, favorite)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON CONFLICT(lat_lon) DO UPDATE SET favorite = excluded.favorite
|
|
||||||
"""
|
|
||||||
FAVORITE_CLEAR_SQL = "UPDATE location_cache SET favorite = NULL WHERE lat_lon = ?"
|
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("ios-api")
|
||||||
|
CACHE_LOOKUP_SQL = "SELECT l.address AS address, f.favorite as favorite, f.category_id as category_id FROM location_cache l LEFT JOIN favorites f ON l.favorite_id = f.favorite_id WHERE l.lat_lon = ?"
|
||||||
|
CACHE_UPSERT_SQL = "INSERT INTO location_cache (lat_lon, address) VALUES (?, ?) ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address"
|
||||||
|
CATEGORY_LOOKUP_SQL = "SELECT c.category_id, c.name, c.icon, c.color, GROUP_CONCAT(f.favorite_id, ', ') AS favorite_list FROM categories c LEFT JOIN favorites f ON c.category_id = f.category_id GROUP BY c.category_id WHERE category_id = ?"
|
||||||
|
CATEGORIES_LOOKUP_SQL = "SELECT c.category_id, c.name, c.icon, c.color, GROUP_CONCAT(f.favorite_id, ', ') AS favorite_list FROM categories c LEFT JOIN favorites f ON c.category_id = f.category_id GROUP BY c.category_id"
|
||||||
|
CATEGORY_CLEAR_SQL = "DELETE FROM categories WHERE category_id = ?"
|
||||||
|
CATEGORY_UPSERT_SQL = "INSERT INTO categories (name, icon, color) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET icon = excluded.icon, color = excluded.color"
|
||||||
|
FAVORITES_LOOKUP_SQL = "SELECT f.favorite_id, f.favorite, f.category_id from favorites f"
|
||||||
|
FAVORITE_LOOKUP_SQL = "SELECT f.favorite, f.category_id from favorites f WHERE f.favorite_id = ?"
|
||||||
|
FAVORITE_CLEAR_SQL = "DELETE FROM favorites WHERE favorite_id = ?"
|
||||||
|
FAVORITE_UPSERT_SQL = "INSERT INTO favorites (name, favorite, category_id) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET favorite = excluded.favorite, category_id = excluded.category_id"
|
||||||
|
FAVORITE_CACHE_UPSERT_SQL = "INSERT INTO location_cache (lat_lon, favorite_id) VALUES (?, ?) ON CONFLICT(lat_lon) DO UPDATE SET favorite_id = excluded.favorite_id"
|
||||||
|
FAVORITE_CACHE_ALL_INSERT_SQL = "INSERT INTO location_cache (lat_lon, address, favorite_id) VALUES (?, ?, ?) ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address, favorite_id = excluded.favorite_id"
|
||||||
|
|
||||||
class AsyncReverseGeocoder:
|
class AsyncReverseGeocoder:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -35,104 +37,268 @@ class AsyncReverseGeocoder:
|
|||||||
"""Initializes the SQLite database."""
|
"""Initializes the SQLite database."""
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
category_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE,
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
favorite_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE,
|
||||||
|
favorite TEXT,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories (category_id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS location_cache (
|
CREATE TABLE IF NOT EXISTS location_cache (
|
||||||
lat_lon TEXT PRIMARY KEY,
|
lat_lon TEXT PRIMARY KEY,
|
||||||
address TEXT,
|
address TEXT,
|
||||||
favorite TEXT
|
FOREIGN KEY (favorite_id) REFERENCES favorites (favorite_id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
cursor.execute("PRAGMA table_info(location_cache)")
|
cursor.execute("PRAGMA table_info(location_cache)")
|
||||||
columns = {row[1] for row in cursor.fetchall()}
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
if "favorite" not in columns:
|
if "favorite" in columns:
|
||||||
cursor.execute("ALTER TABLE location_cache ADD COLUMN favorite TEXT")
|
cursor.execute("ALTER TABLE location_cache DROP COLUMN favorite")
|
||||||
|
if "favorite_id" not in columns:
|
||||||
|
cursor.execute('''
|
||||||
|
ALTER TABLE location_cache
|
||||||
|
ADD COLUMN favorite_id INTEGER REFERENCES favorites (favorite_id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE SET NULL
|
||||||
|
''')
|
||||||
|
cursor.execute("PRAGMA table_info(favorites)")
|
||||||
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
if "category_id" not in columns:
|
||||||
|
cursor.execute('''
|
||||||
|
ALTER TABLE favorites
|
||||||
|
ADD COLUMN category_id INTEGER REFERENCES categories (category_id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE SET NULL
|
||||||
|
''')
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cache_key(lat: float, lon: float) -> str:
|
def _cache_key(lat: float, lon: float) -> str:
|
||||||
return f"{lat:.5f},{lon:.5f}"
|
return f"{lat:.5f},{lon:.5f}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def trace_callback(sql):
|
||||||
|
logger.info("SQL: %s", sql)
|
||||||
|
|
||||||
def _read_cached_address(self, key: str) -> dict | None:
|
def _read_cached_address(self, key: str) -> dict | None:
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.set_trace_callback(self.trace_callback)
|
||||||
cursor = conn.execute(CACHE_LOOKUP_SQL, (key,))
|
cursor = conn.execute(CACHE_LOOKUP_SQL, (key,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
cached = {}
|
cached = {}
|
||||||
if row:
|
if row:
|
||||||
cached['address'] = json.loads(row[0]) if row[0] else {}
|
address = json.loads(row[0]) if row[0] else {}
|
||||||
cached['favorite'] = json.loads(row[1]) if row[1] else {}
|
favorite = json.loads(row[1]) if row[1] else {}
|
||||||
|
favorite['category_id'] = row[2] if row[2] else None
|
||||||
|
cached['address'] = address
|
||||||
|
cached['favorite'] = favorite
|
||||||
return cached if row else None
|
return cached if row else None
|
||||||
|
|
||||||
|
def _read_favorite(self, favorite_id: int) -> dict | None:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = conn.execute(FAVORITE_LOOKUP_SQL, (favorite_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
for row_id, json_str, cat_id in row:
|
||||||
|
favorite = json.loads(json_str)
|
||||||
|
favorite['id'] = row_id
|
||||||
|
favorite['category_id'] = cat_id
|
||||||
|
return favorite if favorite else None
|
||||||
|
|
||||||
|
def _read_favorites(self) -> list | None:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = conn.execute(FAVORITES_LOOKUP_SQL)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
favorites = []
|
||||||
|
if rows:
|
||||||
|
for row_id, json_str, cat_id in rows:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
data['id'] = row_id
|
||||||
|
data['category_id'] = cat_id
|
||||||
|
favorites.append(data)
|
||||||
|
return favorites if favorites else None
|
||||||
|
|
||||||
|
def _read_category(self, category_id: int) -> dict | None:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = conn.execute(CATEGORY_LOOKUP_SQL, (category_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row[1] if row else None
|
||||||
|
|
||||||
|
def _read_categories(self) -> list | None:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = conn.execute(CATEGORIES_LOOKUP_SQL)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
categories = []
|
||||||
|
if rows:
|
||||||
|
for row_id, name, icon, color, favs in rows:
|
||||||
|
fav_list = favs.split(',') if favs else []
|
||||||
|
data = {'id': row_id, 'name': name, 'icon': icon, 'color': color, 'favorite_list': fav_list}
|
||||||
|
categories.append(data)
|
||||||
|
return categories if categories else None
|
||||||
|
|
||||||
def _store_cached_address(self, key: str, address_data: dict) -> None:
|
def _store_cached_address(self, key: str, address_data: dict) -> None:
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
conn.execute(CACHE_UPSERT_SQL, (key, json.dumps(address_data)))
|
conn.execute(CACHE_UPSERT_SQL, (key, json.dumps(address_data)))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def _read_favorites(self) -> list | None:
|
def _set_favorite(self, key: str, favorite_id: int) -> None:
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
cursor = conn.execute(FAVORITE_LOOKUP_SQL)
|
conn.execute(FAVORITE_CACHE_UPSERT_SQL, (key, favorite_id))
|
||||||
rows = cursor.fetchall()
|
conn.commit()
|
||||||
favorites = [json.loads(row[0]) for row in rows if row[0]]
|
|
||||||
return favorites if favorites else None
|
|
||||||
|
|
||||||
async def clear_favorite(self, lat: float, lon: float) -> bool:
|
def _store_favorite(self, key: str, name: str, category_id: int, favorite_data: Favorite) -> None:
|
||||||
key = self._cache_key(lat, lon)
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
cached = self._read_cached_address(key)
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
if cached and cached.get("favorite"):
|
cursor = conn.execute(FAVORITE_UPSERT_SQL, (name, json.dumps(favorite_data), category_id))
|
||||||
favorite = cached.get("favorite")
|
favorite_id = cursor.lastrowid
|
||||||
name = favorite.get("name") if isinstance(favorite, dict) else key
|
conn.commit()
|
||||||
logger.info("Clearing favorite %s", name)
|
if favorite_id is not None:
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
self._set_favorite(key, favorite_id)
|
||||||
cursor = conn.execute(FAVORITE_CLEAR_SQL, (key,))
|
|
||||||
conn.commit()
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def set_favorite(self, lat: float, lon: float, favorite_data: dict) -> bool:
|
def _store_category(self, name: str, icon: str, color: str) -> None:
|
||||||
key = self._cache_key(lat, lon)
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
name = favorite_data.get("name")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
if not name:
|
conn.execute(CATEGORY_UPSERT_SQL, (name, icon, color))
|
||||||
return False
|
conn.commit()
|
||||||
logger.info("Setting favorite location %s to location_cache for %s", name, key)
|
|
||||||
try:
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
|
||||||
conn.execute(FAVORITE_UPSERT_SQL, (key, json.dumps(favorite_data)))
|
|
||||||
conn.commit()
|
|
||||||
return True
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
logger.exception("Failed to set favorite location %s to location_cache for %s: %s", name, key, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_favorites(self) -> list | None:
|
def _store_cached_address_with_favorite(self, key: str, address_data: dict, favorite_id: int) -> None:
|
||||||
"""Reverse geocode with SQLite cache."""
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
logger.info("Checking location_cache for favorites")
|
conn.execute(FAVORITE_CACHE_ALL_INSERT_SQL, (key, json.dumps(address_data), favorite_id))
|
||||||
favorites = self._read_favorites()
|
conn.commit()
|
||||||
return favorites
|
|
||||||
|
|
||||||
async def get_address(self, lat: float, lon: float) -> dict | None:
|
def _clear_favorite(self, favorite_id: int) -> None:
|
||||||
"""Reverse geocode with SQLite cache."""
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
key = self._cache_key(lat, lon)
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
cached_address = self._read_cached_address(key)
|
conn.execute(FAVORITE_CLEAR_SQL, (favorite_id,))
|
||||||
if cached_address is not None:
|
conn.commit()
|
||||||
logger.info("Found cached address for %s", key)
|
|
||||||
return cached_address
|
|
||||||
|
|
||||||
|
def _delete_category(self, category_id: int) -> None:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute(CATEGORY_CLEAR_SQL, (category_id,))
|
||||||
|
|
||||||
|
async def _lookup_address(self, key: str) -> dict | None:
|
||||||
async with Nominatim(
|
async with Nominatim(
|
||||||
user_agent=self.user_agent,
|
user_agent=self.user_agent,
|
||||||
adapter_factory=AioHTTPAdapter
|
adapter_factory=AioHTTPAdapter
|
||||||
) as geolocator:
|
) as geolocator:
|
||||||
try:
|
try:
|
||||||
reverse = AsyncRateLimiter(
|
reverse = AsyncRateLimiter(
|
||||||
geolocator.reverse, min_delay_seconds=1)
|
geolocator.reverse, min_delay_seconds=1)
|
||||||
location = await reverse(key)
|
location = await reverse(key)
|
||||||
if location:
|
if location:
|
||||||
response = {}
|
|
||||||
logger.info("Nominatim response: %s", location)
|
logger.info("Nominatim response: %s", location)
|
||||||
response['address'] = location.raw.get("address", {})
|
return location.raw.get("address", {})
|
||||||
self._store_cached_address(key, response['address'])
|
|
||||||
return response
|
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Reverse geocoding failed for key=%s", key)
|
logger.exception("Reverse geocoding failed for key=%s", key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_favorites(self) -> list | None:
|
||||||
|
logger.info("Checking location_cache for favorites")
|
||||||
|
return self._read_favorites()
|
||||||
|
|
||||||
|
def get_categories(self) -> list | None:
|
||||||
|
logger.info("Checking location_cache for categories")
|
||||||
|
return self._read_categories()
|
||||||
|
|
||||||
|
def clear_favorite(self, favorite_id: int) -> bool:
|
||||||
|
favorite = self._read_favorite(favorite_id)
|
||||||
|
if favorite:
|
||||||
|
favorite_name = favorite.get("name") if isinstance(favorite, dict) else favorite_id
|
||||||
|
logger.info("Clearing favorite %s", favorite_name)
|
||||||
|
self._clear_favorite(favorite_id)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_category(self, category_id: int) -> bool:
|
||||||
|
category = self._read_category(category_id)
|
||||||
|
if category:
|
||||||
|
category_name = category.get("name") if isinstance(category, dict) else category_id
|
||||||
|
logger.info("Clearing category %s", category_name)
|
||||||
|
self._delete_category(category_id)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add_favorite(self, lat: float, lon: float, category_id: int, favorite_data: Favorite) -> bool:
|
||||||
|
key = self._cache_key(lat, lon)
|
||||||
|
name = favorite_data.get("name")
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
logger.info("Adding favorite location %s to favorite database for %s", name, key)
|
||||||
|
self._store_favorite(key, name, category_id, favorite_data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_category(self, name: str, icon: str, color: str) -> bool:
|
||||||
|
logger.info("Adding category %s to category database", name)
|
||||||
|
self._store_category(name, icon, color)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_address(self, lat: float, lon: float) -> dict | None:
|
||||||
|
key = self._cache_key(lat, lon)
|
||||||
|
logger.info("Checking location_cache for %s", key)
|
||||||
|
cached_address = self._read_cached_address(key)
|
||||||
|
response = {}
|
||||||
|
if cached_address:
|
||||||
|
response['address'] = cached_address.get("address")
|
||||||
|
else:
|
||||||
|
address = await self._lookup_address(key)
|
||||||
|
if address is not None:
|
||||||
|
response['address'] = address
|
||||||
|
self._store_cached_address(key, response['address'])
|
||||||
|
else:
|
||||||
|
logger.error("No address found for %s", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if cached_address and cached_address.get("favorite"):
|
||||||
|
favorite = cached_address.get("favorite")
|
||||||
|
name = favorite.get("name") if isinstance(favorite, dict) else key
|
||||||
|
logger.info("Found favorite: %s", name)
|
||||||
|
response['favorite'] = favorite
|
||||||
|
else:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor = conn.execute(FAVORITES_LOOKUP_SQL)
|
||||||
|
results = cursor.fetchall()
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
favorite_id = row[0]
|
||||||
|
favorite_data = json.loads(row[1])
|
||||||
|
favorite_data['id'] = favorite_id
|
||||||
|
|
||||||
|
favorite_coords = (favorite_data.get("latitude"), favorite_data.get("longitude"))
|
||||||
|
radius = favorite_data.get("radius")
|
||||||
|
|
||||||
|
distance = geodesic((lat, lon), favorite_coords).meters
|
||||||
|
logger.info("Distance from %s to favorite %s: %s meters", key, favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown", distance)
|
||||||
|
if distance <= radius:
|
||||||
|
response['favorite'] = favorite_data
|
||||||
|
logger.info("Coordinatees are within favorite radius: %s, setting favorite", favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown")
|
||||||
|
self._set_favorite(key, favorite_id)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.info("Coordinates are not within favorite radius, favorite: %s", favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown")
|
||||||
|
response['favorite'] = None
|
||||||
|
return response
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import fastapi
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
from .json_formatter import logger
|
||||||
|
from .models import LatLng, SimulationRequestData
|
||||||
|
|
||||||
|
|
||||||
|
def generate_http_response(
|
||||||
|
data: dict, status_code: int = 200, media_type: str = "application/json"
|
||||||
|
) -> fastapi.Response:
|
||||||
|
return fastapi.Response(
|
||||||
|
status_code=status_code,
|
||||||
|
media_type=media_type,
|
||||||
|
content=json.dumps(jsonable_encoder(data)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_http_routes(
|
||||||
|
router: APIRouter,
|
||||||
|
context,
|
||||||
|
tunnel_service,
|
||||||
|
icloud_service,
|
||||||
|
simulation_service,
|
||||||
|
) -> None:
|
||||||
|
@router.get("/device/name")
|
||||||
|
async def rsd_info():
|
||||||
|
device_name = await tunnel_service.get_device_name()
|
||||||
|
return generate_http_response(device_name)
|
||||||
|
|
||||||
|
@router.get("/device/info")
|
||||||
|
async def device_info():
|
||||||
|
return await tunnel_service.device_info()
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_tunnels() -> dict[str, list[dict]]:
|
||||||
|
return tunnel_service.list_tunnels()
|
||||||
|
|
||||||
|
@router.get("/shutdown")
|
||||||
|
async def shutdown() -> fastapi.Response:
|
||||||
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
data = {
|
||||||
|
"operation": "shutdown",
|
||||||
|
"data": True,
|
||||||
|
"message": "Server shutting down...",
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/clear-tunnels")
|
||||||
|
async def clear_tunnels() -> fastapi.Response:
|
||||||
|
return generate_http_response(tunnel_service.clear_tunnels_response())
|
||||||
|
|
||||||
|
@router.get("/cancel")
|
||||||
|
async def cancel_tunnel(udid: str) -> fastapi.Response:
|
||||||
|
return generate_http_response(tunnel_service.cancel_tunnel_response(udid))
|
||||||
|
|
||||||
|
@router.get("/hello")
|
||||||
|
async def hello() -> fastapi.Response:
|
||||||
|
return generate_http_response({"message": "Hello, I'm alive"})
|
||||||
|
|
||||||
|
@router.get("/start-tunnel")
|
||||||
|
async def start_tunnel(
|
||||||
|
udid: str | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
connection_type: str | None = None,
|
||||||
|
) -> fastapi.Response:
|
||||||
|
data = await tunnel_service.start_tunnel_response(
|
||||||
|
udid=udid,
|
||||||
|
ip=ip,
|
||||||
|
connection_type=connection_type,
|
||||||
|
)
|
||||||
|
if isinstance(data, fastapi.Response):
|
||||||
|
return data
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/restart")
|
||||||
|
async def restart() -> fastapi.Response:
|
||||||
|
return generate_http_response(await tunnel_service.restart_response())
|
||||||
|
|
||||||
|
@router.get("/icloud-monitor/start")
|
||||||
|
async def app_start_icloud_monitor() -> fastapi.Response:
|
||||||
|
await icloud_service.start()
|
||||||
|
data = {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "icloud_monitor",
|
||||||
|
"command": "start",
|
||||||
|
"icloud_monitor_enabled": context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": icloud_service.is_running(),
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/icloud-monitor/stop")
|
||||||
|
async def app_stop_icloud_monitor() -> fastapi.Response:
|
||||||
|
await icloud_service.stop()
|
||||||
|
data = {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "icloud_monitor",
|
||||||
|
"command": "stop",
|
||||||
|
"icloud_monitor_enabled": context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": icloud_service.is_running(),
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/icloud-monitor/status")
|
||||||
|
async def app_icloud_monitor_status() -> fastapi.Response:
|
||||||
|
data = {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "icloud_monitor",
|
||||||
|
"command": "status",
|
||||||
|
"icloud_monitor_enabled": context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": icloud_service.is_running(),
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/icloud-monitor/refresh")
|
||||||
|
async def app_refresh_icloud_monitor() -> fastapi.Response:
|
||||||
|
data = await icloud_service.refresh_location()
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/simulation/start")
|
||||||
|
async def app_start_simulation() -> fastapi.Response:
|
||||||
|
data = await simulation_service.start_queue()
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.post("/simulation/add")
|
||||||
|
async def app_add_location(data: SimulationRequestData) -> fastapi.Response:
|
||||||
|
logger.info("Request to add new location to queue")
|
||||||
|
resp = await simulation_service.add_location_to_queue(data)
|
||||||
|
return generate_http_response(resp)
|
||||||
|
|
||||||
|
@router.get("/simulation/clear")
|
||||||
|
async def app_clear_queue() -> fastapi.Response:
|
||||||
|
logger.info("Simulation Start Requested ")
|
||||||
|
simulation_service.clear_future_items()
|
||||||
|
data = {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": "clear",
|
||||||
|
"message": "Simulation cleared",
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/simulation/pause")
|
||||||
|
async def app_pause_queue() -> fastapi.Response:
|
||||||
|
await simulation_service.pause_queue()
|
||||||
|
data = {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": "pause",
|
||||||
|
"message": "Simulation paused",
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/simulation/resume")
|
||||||
|
async def app_resume_queue() -> fastapi.Response:
|
||||||
|
await simulation_service.resume_queue()
|
||||||
|
data = {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": "resume",
|
||||||
|
"message": "Simulation resumed",
|
||||||
|
}
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/simulation/end")
|
||||||
|
async def app_end_simulation() -> fastapi.Response:
|
||||||
|
data = await simulation_service.end_queue()
|
||||||
|
return generate_http_response(data)
|
||||||
|
|
||||||
|
@router.get("/simulation/test")
|
||||||
|
async def app_simulation_test_mode() -> fastapi.Response:
|
||||||
|
before_toggle = context.test_mode
|
||||||
|
response = {
|
||||||
|
"data": simulation_service.toggle_test_mode(),
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": "test_mode",
|
||||||
|
"message": f"Test mode toggled from {before_toggle} to {context.test_mode}",
|
||||||
|
}
|
||||||
|
return generate_http_response(response)
|
||||||
|
|
||||||
|
@router.get("/simulation/noise")
|
||||||
|
async def app_simulation_noise() -> fastapi.Response:
|
||||||
|
before_toggle = context.simulation_noise
|
||||||
|
response = {
|
||||||
|
"data": simulation_service.toggle_gps_noise(),
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": "noise",
|
||||||
|
"message": f"GPS noise toggled from {before_toggle} to {context.simulation_noise}",
|
||||||
|
}
|
||||||
|
return generate_http_response(response)
|
||||||
|
|
||||||
|
@router.get("/status/rsd")
|
||||||
|
async def app_rsd_info():
|
||||||
|
rsd_info = {}
|
||||||
|
if context.tunnel is None:
|
||||||
|
await tunnel_service.get_tun()
|
||||||
|
if context.tunnel is not None:
|
||||||
|
rsd_info = context.tunnel.peer_info
|
||||||
|
return generate_http_response(rsd_info)
|
||||||
|
|
||||||
|
@router.get("/status/context")
|
||||||
|
async def app_context_status() -> fastapi.Response:
|
||||||
|
return generate_http_response(simulation_service.get_status())
|
||||||
|
|
||||||
|
@router.post("/rev_geocode")
|
||||||
|
async def app_proxy_osm(data: LatLng):
|
||||||
|
logger.info("OSM Proxy Request, data: %s", data)
|
||||||
|
rev_geocode = await simulation_service.get_reverse_geocode(data)
|
||||||
|
return generate_http_response(rev_geocode)
|
||||||
|
|
||||||
|
@router.get("/ors/status")
|
||||||
|
async def app_ors_status(request: Request) -> fastapi.Response:
|
||||||
|
logger.info("request: %s", request)
|
||||||
|
target_url = "https://ors.intrepidnet.org/ors/v2/status"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(target_url, params=request.query_params)
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/ors/health")
|
||||||
|
async def app_ors_health(request: Request) -> fastapi.Response:
|
||||||
|
logger.info("request: %s", request)
|
||||||
|
target_url = "https://ors.intrepidnet.org/ors/v2/health"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(target_url, params=request.query_params)
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/ors/proxy/{full_path:path}")
|
||||||
|
async def app_proxy_ors(full_path: str, request: Request):
|
||||||
|
logger.info("request: %s", request)
|
||||||
|
body = await request.json()
|
||||||
|
headers = dict(request.headers)
|
||||||
|
method = request.method
|
||||||
|
url = "https://ors.intrepidnet.org/" + full_path
|
||||||
|
logger.info("body: %s", body)
|
||||||
|
logger.info("headers: %s", headers)
|
||||||
|
logger.info("method: %s", method)
|
||||||
|
logger.info("url: %s", url)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.request(method, url, json=body)
|
||||||
|
return response.json()
|
||||||
@@ -37,6 +37,7 @@ AUTH_EXCEPTIONS = (
|
|||||||
PyiCloudNoStoredPasswordAvailableException,
|
PyiCloudNoStoredPasswordAvailableException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FindMyMonitor:
|
class FindMyMonitor:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -59,9 +60,7 @@ class FindMyMonitor:
|
|||||||
self.sio = sio
|
self.sio = sio
|
||||||
self.get_client_sids = get_client_sids
|
self.get_client_sids = get_client_sids
|
||||||
self.code_timeout_seconds = code_timeout_seconds
|
self.code_timeout_seconds = code_timeout_seconds
|
||||||
self.auth_init_timeout_seconds = int(
|
self.auth_init_timeout_seconds = int(os.getenv(ENV_AUTH_INIT_TIMEOUT, "0"))
|
||||||
os.getenv(ENV_AUTH_INIT_TIMEOUT, "0")
|
|
||||||
)
|
|
||||||
self._logged_candidates = False
|
self._logged_candidates = False
|
||||||
self._no_location_streak = 0
|
self._no_location_streak = 0
|
||||||
self._auth_error_streak = 0
|
self._auth_error_streak = 0
|
||||||
@@ -122,7 +121,9 @@ class FindMyMonitor:
|
|||||||
async def authenticate(self) -> bool:
|
async def authenticate(self) -> bool:
|
||||||
"""Authenticates with iCloud, handling 2FA and token storage."""
|
"""Authenticates with iCloud, handling 2FA and token storage."""
|
||||||
if not self.username:
|
if not self.username:
|
||||||
logger.warning("APPLE_ID is not configured; skipping iCloud monitor authentication")
|
logger.warning(
|
||||||
|
"APPLE_ID is not configured; skipping iCloud monitor authentication"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
has_token = os.path.exists(self.token_file)
|
has_token = os.path.exists(self.token_file)
|
||||||
if not has_token and not self.password:
|
if not has_token and not self.password:
|
||||||
@@ -140,7 +141,9 @@ class FindMyMonitor:
|
|||||||
|
|
||||||
if self.api.requires_2fa:
|
if self.api.requires_2fa:
|
||||||
logger.info("Two-factor authentication required.")
|
logger.info("Two-factor authentication required.")
|
||||||
code = await self._request_code_from_vue("Enter the 6-digit Apple verification code")
|
code = await self._request_code_from_vue(
|
||||||
|
"Enter the 6-digit Apple verification code"
|
||||||
|
)
|
||||||
if code is None:
|
if code is None:
|
||||||
if sys.stdin and sys.stdin.isatty():
|
if sys.stdin and sys.stdin.isatty():
|
||||||
code = str(
|
code = str(
|
||||||
@@ -165,10 +168,12 @@ class FindMyMonitor:
|
|||||||
await asyncio.to_thread(self.api.trust_session)
|
await asyncio.to_thread(self.api.trust_session)
|
||||||
|
|
||||||
if self.api.requires_2sa:
|
if self.api.requires_2sa:
|
||||||
logger.info(textwrap.dedent("""
|
logger.info(
|
||||||
|
textwrap.dedent("""
|
||||||
Two-step authentication required.
|
Two-step authentication required.
|
||||||
Please select a device to receive a SMS verification code:
|
Please select a device to receive a SMS verification code:
|
||||||
"""))
|
""")
|
||||||
|
)
|
||||||
for i, device in enumerate(self.api.trusted_devices):
|
for i, device in enumerate(self.api.trusted_devices):
|
||||||
logger.info(
|
logger.info(
|
||||||
" %s: %s (%s)",
|
" %s: %s (%s)",
|
||||||
@@ -182,14 +187,23 @@ class FindMyMonitor:
|
|||||||
"2SA required but no interactive terminal is available; deferring authentication"
|
"2SA required but no interactive terminal is available; deferring authentication"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
device_index = await asyncio.to_thread(click.prompt, "Please select a device number", type=int) - 1
|
device_index = (
|
||||||
|
await asyncio.to_thread(
|
||||||
|
click.prompt, "Please select a device number", type=int
|
||||||
|
)
|
||||||
|
- 1
|
||||||
|
)
|
||||||
device = self.api.trusted_devices[device_index]
|
device = self.api.trusted_devices[device_index]
|
||||||
if not await asyncio.to_thread(self.api.send_verification_code, device):
|
if not await asyncio.to_thread(self.api.send_verification_code, device):
|
||||||
logger.warning("Failed to send verification code")
|
logger.warning("Failed to send verification code")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
code = await asyncio.to_thread(click.prompt, "Please enter verification code", type=int)
|
code = await asyncio.to_thread(
|
||||||
if not await asyncio.to_thread(self.api.validate_verification_code, device, code):
|
click.prompt, "Please enter verification code", type=int
|
||||||
|
)
|
||||||
|
if not await asyncio.to_thread(
|
||||||
|
self.api.validate_verification_code, device, code
|
||||||
|
):
|
||||||
logger.warning("Failed to verify verification code")
|
logger.warning("Failed to verify verification code")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -223,15 +237,25 @@ class FindMyMonitor:
|
|||||||
# Select by ID first (exact), then by name, then first device if no selectors set.
|
# Select by ID first (exact), then by name, then first device if no selectors set.
|
||||||
self.selected_device = None
|
self.selected_device = None
|
||||||
for device in devices_manager:
|
for device in devices_manager:
|
||||||
if self.selected_device_id and device.data.get("id") == self.selected_device_id:
|
if (
|
||||||
|
self.selected_device_id
|
||||||
|
and device.data.get("id") == self.selected_device_id
|
||||||
|
):
|
||||||
self.selected_device = device
|
self.selected_device = device
|
||||||
break
|
break
|
||||||
if self.selected_device is None:
|
if self.selected_device is None:
|
||||||
for device in devices_manager:
|
for device in devices_manager:
|
||||||
if self.selected_device_name and device.name == self.selected_device_name:
|
if (
|
||||||
|
self.selected_device_name
|
||||||
|
and device.name == self.selected_device_name
|
||||||
|
):
|
||||||
self.selected_device = device
|
self.selected_device = device
|
||||||
break
|
break
|
||||||
if self.selected_device is None and not self.selected_device_id and not self.selected_device_name:
|
if (
|
||||||
|
self.selected_device is None
|
||||||
|
and not self.selected_device_id
|
||||||
|
and not self.selected_device_name
|
||||||
|
):
|
||||||
for device in devices_manager:
|
for device in devices_manager:
|
||||||
self.selected_device = device
|
self.selected_device = device
|
||||||
break
|
break
|
||||||
@@ -247,20 +271,22 @@ class FindMyMonitor:
|
|||||||
)
|
)
|
||||||
if location:
|
if location:
|
||||||
data = {
|
data = {
|
||||||
"latitude": location['latitude'],
|
"latitude": location["latitude"],
|
||||||
"longitude": location['longitude'],
|
"longitude": location["longitude"],
|
||||||
"timeStamp": location['timeStamp'],
|
"timeStamp": location["timeStamp"],
|
||||||
"altitude": location['altitude'],
|
"altitude": location["altitude"],
|
||||||
"horizontalAccuracy": location['horizontalAccuracy'],
|
"horizontalAccuracy": location["horizontalAccuracy"],
|
||||||
"verticalAccuracy": location['verticalAccuracy'],
|
"verticalAccuracy": location["verticalAccuracy"],
|
||||||
"batteryLevel": status['batteryLevel'],
|
"batteryLevel": status["batteryLevel"],
|
||||||
"deviceDisplayName": status['deviceDisplayName'],
|
"deviceDisplayName": status["deviceDisplayName"],
|
||||||
"deviceStatus": status['deviceStatus'],
|
"deviceStatus": status["deviceStatus"],
|
||||||
"name": status['name']
|
"name": status["name"],
|
||||||
}
|
}
|
||||||
response = ICloudReturnData(**data)
|
response = ICloudReturnData(**data)
|
||||||
return response
|
return response
|
||||||
logger.info("Location payload is None for device=%s", self.selected_device.name)
|
logger.info(
|
||||||
|
"Location payload is None for device=%s", self.selected_device.name
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -287,8 +313,15 @@ class FindMyMonitor:
|
|||||||
|
|
||||||
async def refresh_location(self):
|
async def refresh_location(self):
|
||||||
"""Fetch one location update while serializing iCloud API access."""
|
"""Fetch one location update while serializing iCloud API access."""
|
||||||
|
logger.info(
|
||||||
|
"Refreshig fmf_location for device id='%s' name='%s'",
|
||||||
|
self.selected_device_id,
|
||||||
|
self.selected_device_name,
|
||||||
|
)
|
||||||
async with self._fetch_lock:
|
async with self._fetch_lock:
|
||||||
return await self.get_location()
|
fmf_info = await self.get_location()
|
||||||
|
logger.info("fmf_location: '%s'", fmf_info)
|
||||||
|
return fmf_info
|
||||||
|
|
||||||
async def run_monitor(self, interval=60):
|
async def run_monitor(self, interval=60):
|
||||||
"""Runs the monitor loop."""
|
"""Runs the monitor loop."""
|
||||||
@@ -338,5 +371,5 @@ class FindMyMonitor:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("iCloud monitor loop error: %s", e)
|
logger.exception("iCloud monitor loop error: %s", e)
|
||||||
sleep_seconds = max(interval, 30)
|
sleep_seconds = max(interval, 30)
|
||||||
|
|
||||||
await asyncio.sleep(sleep_seconds)
|
await asyncio.sleep(sleep_seconds)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from .json_formatter import logger
|
||||||
|
|
||||||
|
|
||||||
|
class ICloudMonitorService:
|
||||||
|
def __init__(self, context):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start Apple iCloud Find My Monitor to retrieve actual reported device location."""
|
||||||
|
logger.info("iCloud monitor start requested")
|
||||||
|
self.context.icloud_monitor_enabled = True
|
||||||
|
if (
|
||||||
|
self.context.icloud_monitor_task is None
|
||||||
|
or self.context.icloud_monitor_task.done()
|
||||||
|
):
|
||||||
|
self.context.icloud_monitor_task = asyncio.create_task(
|
||||||
|
self.context.icloud_monitor.run_monitor(interval=30),
|
||||||
|
name="icloud-monitor-producer",
|
||||||
|
)
|
||||||
|
logger.info("iCloud monitor producer task started")
|
||||||
|
if (
|
||||||
|
self.context.icloud_consumer_task is None
|
||||||
|
or self.context.icloud_consumer_task.done()
|
||||||
|
):
|
||||||
|
self.context.icloud_consumer_task = asyncio.create_task(
|
||||||
|
self.consume_updates(),
|
||||||
|
name="icloud-monitor-consumer",
|
||||||
|
)
|
||||||
|
logger.info("iCloud monitor consumer task started")
|
||||||
|
|
||||||
|
async def consume_updates(self):
|
||||||
|
while True:
|
||||||
|
updated_location = await self.context.fmf_queue.get()
|
||||||
|
try:
|
||||||
|
if self.context.fmf_location != updated_location:
|
||||||
|
self.context.fmf_location = updated_location
|
||||||
|
await self.context.sio.emit(
|
||||||
|
"fmf_update", updated_location.model_dump(), namespace="/"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.context.fmf_queue.task_done()
|
||||||
|
|
||||||
|
async def refresh_location(self) -> dict:
|
||||||
|
"""Fetch one iCloud location update, with or without monitor loop running."""
|
||||||
|
logger.info("iCloud monitor refresh requested")
|
||||||
|
try:
|
||||||
|
updated_location = await self.context.icloud_monitor.refresh_location()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to refresh iCloud location: %s", e)
|
||||||
|
return {
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"command_class": "icloud_monitor",
|
||||||
|
"command": "refresh",
|
||||||
|
"message": "Failed to refresh iCloud location",
|
||||||
|
"error": type(e).__name__,
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_running(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated_location is None:
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "icloud_monitor",
|
||||||
|
"command": "refresh",
|
||||||
|
"location_found": False,
|
||||||
|
"location_updated": False,
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_running(),
|
||||||
|
}
|
||||||
|
|
||||||
|
location_updated = self.context.fmf_location != updated_location
|
||||||
|
self.context.fmf_location = updated_location
|
||||||
|
await self.context.sio.emit(
|
||||||
|
"fmf_update", updated_location.model_dump(), namespace="/"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "icloud_monitor",
|
||||||
|
"command": "refresh",
|
||||||
|
"location_found": True,
|
||||||
|
"location_updated": location_updated,
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_running(),
|
||||||
|
"fmf_location": updated_location,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
logger.info("iCloud monitor stop requested")
|
||||||
|
self.context.icloud_monitor_enabled = False
|
||||||
|
self.context.icloud_monitor.stop()
|
||||||
|
if self.context.icloud_monitor_task is not None:
|
||||||
|
self.context.icloud_monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.context.icloud_monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self.context.icloud_monitor_task = None
|
||||||
|
if self.context.icloud_consumer_task is not None:
|
||||||
|
self.context.icloud_consumer_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.context.icloud_consumer_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self.context.icloud_consumer_task = None
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.context.icloud_monitor_task is not None
|
||||||
|
and not self.context.icloud_monitor_task.done()
|
||||||
|
and self.context.icloud_consumer_task is not None
|
||||||
|
and not self.context.icloud_consumer_task.done()
|
||||||
|
)
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, TypedDict, NotRequired
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class Favorite(TypedDict):
|
||||||
|
id: NotRequired[int]
|
||||||
|
name: str
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
radius: float
|
||||||
|
icon: str
|
||||||
|
address: dict[str, str | None]
|
||||||
|
|
||||||
|
class Category(TypedDict):
|
||||||
|
category_id: NotRequired[int]
|
||||||
|
name: str
|
||||||
|
icon: str
|
||||||
|
color: str
|
||||||
|
|
||||||
|
|
||||||
class Coordinate(BaseModel):
|
class Coordinate(BaseModel):
|
||||||
latitude: float
|
latitude: float
|
||||||
|
|||||||
+125
-2426
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,782 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
from .json_formatter import logger
|
||||||
|
from .models import LatLng
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SocketIOEventHandlers:
|
||||||
|
context: Any
|
||||||
|
tunneld_core: Any
|
||||||
|
get_status: Callable[[], dict]
|
||||||
|
get_simulation_status: Callable[[], dict]
|
||||||
|
device_shutdown: Callable[[Any], Awaitable[Any]]
|
||||||
|
device_reboot: Callable[[Any], Awaitable[Any]]
|
||||||
|
advance_simulation_queue: Callable[[], Awaitable[Any]]
|
||||||
|
toggle_test_mode: Callable[[], dict]
|
||||||
|
add_location_to_simulation_queue: Callable[[Any], Awaitable[dict]]
|
||||||
|
delete_location_from_simulation_queue: Callable[[Any], Awaitable[dict]]
|
||||||
|
clear_future_items: Callable[[], Any]
|
||||||
|
reset_queue: Callable[[], Any]
|
||||||
|
pause_simulation_queue: Callable[[], Awaitable[Any]]
|
||||||
|
resume_simulation_queue: Callable[[], Awaitable[Any]]
|
||||||
|
end_simulation_queue: Callable[[], Awaitable[Any]]
|
||||||
|
start_simulation_queue: Callable[[], Awaitable[dict]]
|
||||||
|
restart_simulation_queue: Callable[[], Awaitable[dict]]
|
||||||
|
toggle_gps_noise: Callable[[], dict]
|
||||||
|
update_queue_times: Callable[[], Awaitable[Any]]
|
||||||
|
start_icloud_monitor: Callable[[], Awaitable[Any]]
|
||||||
|
end_icloud_monitor: Callable[[], Awaitable[Any]]
|
||||||
|
is_icloud_monitor_running: Callable[[], bool]
|
||||||
|
refresh_icloud_location: Callable[[], Awaitable[dict]]
|
||||||
|
start_tunnel_watcher: Callable[[], Awaitable[Any]]
|
||||||
|
end_tunnel_watcher: Callable[[], Awaitable[Any]]
|
||||||
|
get_reverse_geocode: Callable[[Any], Awaitable[Any]]
|
||||||
|
set_favorite: Callable[[Any], Awaitable[tuple[str, str]]]
|
||||||
|
clear_favorite: Callable[[Any], Awaitable[tuple[str, str]]]
|
||||||
|
get_favorites: Callable[[], Awaitable[Any]]
|
||||||
|
get_categories: Callable[[], Awaitable[Any]]
|
||||||
|
add_category: Callable[[Any], Awaitable[dict]]
|
||||||
|
delete_category: Callable[[Any], Awaitable[tuple[str, str]]]
|
||||||
|
|
||||||
|
def register(self) -> None:
|
||||||
|
sio = self.context.sio
|
||||||
|
|
||||||
|
async def emit_test_prompt(test_prompt_data):
|
||||||
|
prompt_name = (
|
||||||
|
test_prompt_data.get("prompt_name")
|
||||||
|
if isinstance(test_prompt_data, dict)
|
||||||
|
else getattr(test_prompt_data, "prompt_name", None)
|
||||||
|
)
|
||||||
|
client_id = (
|
||||||
|
test_prompt_data.get("client_id")
|
||||||
|
if isinstance(test_prompt_data, dict)
|
||||||
|
else getattr(test_prompt_data, "client_id", None)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Sending test prompt %s sent to client %s", prompt_name, client_id
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await sio.call(
|
||||||
|
"test_prompt",
|
||||||
|
{"prompt_name": prompt_name},
|
||||||
|
namespace="/",
|
||||||
|
to=client_id,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if response and response.get("confirmed"):
|
||||||
|
await do_positive_action(prompt_name, client_id)
|
||||||
|
else:
|
||||||
|
await do_negative_action(prompt_name, client_id)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"Client {client_id} failed to respond in time.")
|
||||||
|
|
||||||
|
async def do_positive_action(prompt_name, client_id):
|
||||||
|
logger.info(
|
||||||
|
"Confirmation received from client %s fpr prompt %s",
|
||||||
|
prompt_name,
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def do_negative_action(prompt_name, client_id):
|
||||||
|
logger.info(
|
||||||
|
"Client %s denied confirmation for prompt %s", client_id, prompt_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def sio_send_status(sid):
|
||||||
|
"""Send Current Status"""
|
||||||
|
if sid in self.context.connected_clients:
|
||||||
|
logger.info("Sending status to: %s", sid)
|
||||||
|
await sio.emit(
|
||||||
|
"status", jsonable_encoder(self.get_status()), namespace="/", to=sid
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Sending status to all clients, not only: %s", sid)
|
||||||
|
await sio.emit(
|
||||||
|
"status", jsonable_encoder(self.get_status()), namespace="/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def json_fallback(obj):
|
||||||
|
if hasattr(obj, "__dict__"):
|
||||||
|
return obj.__dict__
|
||||||
|
if hasattr(obj, "as_dict"):
|
||||||
|
return obj.as_dict()
|
||||||
|
if hasattr(obj, "to_dict"):
|
||||||
|
return obj.to_dict()
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ):
|
||||||
|
"""Client connection event handler."""
|
||||||
|
self.context.connected_clients.add(sid)
|
||||||
|
logger.info("Client connected: %s", sid)
|
||||||
|
await sio_send_status(sid)
|
||||||
|
return "%s connected" % sid
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def disconnect(sid):
|
||||||
|
"""Client disconnection event handler."""
|
||||||
|
self.context.connected_clients.discard(sid)
|
||||||
|
logger.info("Client disconnected: %s", sid)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def send_test_prompt(sid, data):
|
||||||
|
"""Send test prompt"""
|
||||||
|
prompt_name = (
|
||||||
|
data.get("prompt_name")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "prompt_name", None)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Received request from %s to send test prompt %s", sid, prompt_name
|
||||||
|
)
|
||||||
|
test_prompt_data = {"prompt_name": prompt_name, "client_id": sid}
|
||||||
|
sio.start_background_task(emit_test_prompt, test_prompt_data)
|
||||||
|
return {
|
||||||
|
"prompt_name": prompt_name,
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "Test prompt requested.",
|
||||||
|
}
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def request_update(sid):
|
||||||
|
status_update = jsonable_encoder(self.get_status())
|
||||||
|
logger.info("Update request from %s sending %s", sid, status_update)
|
||||||
|
return status_update
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def message(sid, data):
|
||||||
|
logger.info("Received message from %s: %s", sid, data)
|
||||||
|
return True, "Message received"
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def device_control(sid, data):
|
||||||
|
"""Device Control"""
|
||||||
|
command = (
|
||||||
|
data.get("command")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "command", None)
|
||||||
|
)
|
||||||
|
delay = (
|
||||||
|
data.get("delay")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "delay", None)
|
||||||
|
)
|
||||||
|
if delay is None:
|
||||||
|
delay = 5
|
||||||
|
match command:
|
||||||
|
case "shutdown":
|
||||||
|
logger.info(
|
||||||
|
"Shutdown command received from %s with delay %s", sid, delay
|
||||||
|
)
|
||||||
|
await self.device_shutdown(delay)
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "device_control",
|
||||||
|
"command": "shutdown",
|
||||||
|
"message": f"Device shutdown initiated with {delay} seconds delay",
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reboot":
|
||||||
|
logger.info(
|
||||||
|
"Reboot command received from %s with delay %s", sid, delay
|
||||||
|
)
|
||||||
|
await self.device_reboot(delay)
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "device_control",
|
||||||
|
"command": "reboot",
|
||||||
|
"message": f"Device reboot initiated with {delay} seconds delay",
|
||||||
|
}
|
||||||
|
|
||||||
|
case _:
|
||||||
|
return {
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"command_class": "device_control",
|
||||||
|
"command": command,
|
||||||
|
"message": f"Invalid command: {command}",
|
||||||
|
}
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def simulation_control(sid, data):
|
||||||
|
"""Simulation Control"""
|
||||||
|
command = (
|
||||||
|
data.get("command")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "command", None)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Simulation Control command: %s requested from %s", command, sid
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
match command:
|
||||||
|
case "next":
|
||||||
|
await self.advance_simulation_queue()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "Advanced simulation queue",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "test-mode":
|
||||||
|
self.toggle_test_mode()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "test-mode toggled",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add":
|
||||||
|
return await self.add_location_to_simulation_queue(data)
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
return await self.delete_location_from_simulation_queue(data)
|
||||||
|
|
||||||
|
case "clear":
|
||||||
|
self.clear_future_items()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "Simulation cleared",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reset":
|
||||||
|
self.reset_queue()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "Simulation queue reset",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pause":
|
||||||
|
await self.pause_simulation_queue()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "Simulation paused",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "resume":
|
||||||
|
await self.resume_simulation_queue()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"message": "Simulation resumed",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
logger.info("End location simulation request from %s", sid)
|
||||||
|
cstat = "OK" if await self.end_simulation_queue() else "ERROR"
|
||||||
|
return {
|
||||||
|
"command_status": cstat,
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": command,
|
||||||
|
"message": "Simulation ended",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
logger.info("Start location simulation request from %s", sid)
|
||||||
|
return await self.start_simulation_queue()
|
||||||
|
|
||||||
|
case "gps-noise":
|
||||||
|
before_toggle = self.context.simulation_noise
|
||||||
|
self.toggle_gps_noise()
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "simulation_control",
|
||||||
|
"command": command,
|
||||||
|
"message": f"GPS noise toggled from {before_toggle} to {self.context.simulation_noise}",
|
||||||
|
"data": {
|
||||||
|
"simulation": self.get_simulation_status(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "restart":
|
||||||
|
logger.info("Restart location simulation request from %s", sid)
|
||||||
|
return await self.restart_simulation_queue()
|
||||||
|
|
||||||
|
case _:
|
||||||
|
logger.warning(
|
||||||
|
"Invalid command received from %s: %s", sid, command
|
||||||
|
)
|
||||||
|
return {"command_status": "ERROR", "message": "Invalid command"}
|
||||||
|
finally:
|
||||||
|
logger.info(
|
||||||
|
"Simulation Control command: %s completed, sending status update...",
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
await sio_send_status(sid)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def location_item_update(sid, data):
|
||||||
|
"""Location Item Control"""
|
||||||
|
loc_id = (
|
||||||
|
data.get("loc_id")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "loc_id", None)
|
||||||
|
)
|
||||||
|
key = (
|
||||||
|
data.get("key")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "key", None)
|
||||||
|
)
|
||||||
|
value = (
|
||||||
|
data.get("value")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "value", None)
|
||||||
|
)
|
||||||
|
error_data = {
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"command": "update",
|
||||||
|
"command_class": "location_item",
|
||||||
|
"message": "Invalid request, Location Item Unchanged",
|
||||||
|
"data": self.context.simulation_queue_data[loc_id],
|
||||||
|
}
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
if loc_id and key:
|
||||||
|
old_val = self.context.simulation_queue_data[loc_id][key]
|
||||||
|
self.context.simulation_queue_data[loc_id][key] = value
|
||||||
|
if key == "delay":
|
||||||
|
await self.update_queue_times()
|
||||||
|
logger.info(
|
||||||
|
"Location Item Update: %s: %s changed from %s to %s",
|
||||||
|
loc_id,
|
||||||
|
key,
|
||||||
|
old_val,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command": "update",
|
||||||
|
"command_class": "location_item",
|
||||||
|
"message": "Location Item Updated",
|
||||||
|
"data": {
|
||||||
|
"simulation": {
|
||||||
|
"data": {
|
||||||
|
loc_id: self.context.simulation_queue_data[loc_id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return error_data
|
||||||
|
else:
|
||||||
|
return error_data
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def queue_order_update(sid, data):
|
||||||
|
logger.info("Queue order update received: %s", data)
|
||||||
|
new_simulation_queue_order = []
|
||||||
|
new_order = (
|
||||||
|
data.get("newOrder")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "newOrder", None)
|
||||||
|
)
|
||||||
|
if new_order:
|
||||||
|
for item in new_order:
|
||||||
|
if item in self.context.simulation_queue_data:
|
||||||
|
new_simulation_queue_order.append(item)
|
||||||
|
self.context.simulation_queue_order = new_simulation_queue_order
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command": "queue_order_update",
|
||||||
|
"command_class": "queue_order",
|
||||||
|
"message": "Queue Order Updated",
|
||||||
|
"data": {
|
||||||
|
"simulation": {
|
||||||
|
"order" : self.context.simulation_queue_order,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"command": "queue_order_update",
|
||||||
|
"command_class": "queue_order",
|
||||||
|
"message": "Invalid request, Queue Order Unchanged",
|
||||||
|
"data": {
|
||||||
|
"simulation": {
|
||||||
|
"order": self.context.simulation_queue_order,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def icloud_monitor_control(sid, data):
|
||||||
|
command = (
|
||||||
|
data.get("command")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "command", None)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"iCloud Monitor control command: %s requested from %s", command, sid
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
match command:
|
||||||
|
case "start":
|
||||||
|
await self.start_icloud_monitor()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "icloud_monitor_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"data": {
|
||||||
|
"icloud": {
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_icloud_monitor_running(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "stop":
|
||||||
|
await self.end_icloud_monitor()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "icloud_monitor_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"data": {
|
||||||
|
"icloud": {
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_icloud_monitor_running(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "icloud_monitor_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"data": {
|
||||||
|
"icloud": {
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_icloud_monitor_running(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "refresh":
|
||||||
|
resp = {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "icloud_monitor_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"data": {
|
||||||
|
"icloud": {
|
||||||
|
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
|
||||||
|
"icloud_monitor_running": self.is_icloud_monitor_running(),
|
||||||
|
"fmf_location": json.dumps(
|
||||||
|
await self.refresh_icloud_location(),
|
||||||
|
default=json_fallback,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
case _:
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "icloud_monitor_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": "Invalid command",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await sio_send_status(sid)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def tunneld_control(sid, data):
|
||||||
|
command = (
|
||||||
|
data.get("command")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "command", None)
|
||||||
|
)
|
||||||
|
logger.info("Tunneld Control command: %s requested from %s", command, sid)
|
||||||
|
match command:
|
||||||
|
case "start":
|
||||||
|
logger.info("Start tunneld request from %s: %s", sid, data)
|
||||||
|
try:
|
||||||
|
self.tunneld_core.start()
|
||||||
|
logger.info("Tunneld started successfully")
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command": command,
|
||||||
|
"message": "Tunneld started successfully",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error starting tunneld: %s", e)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Error starting tunneld: {e}",
|
||||||
|
}
|
||||||
|
case "restart":
|
||||||
|
logger.info("Restart tunneld request from %s: %s", sid, data)
|
||||||
|
try:
|
||||||
|
self.tunneld_core.clear()
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
self.tunneld_core.start()
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command": command,
|
||||||
|
"message": "Tunneld started successfully",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error restarting tunneld: %s", e)
|
||||||
|
return {
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command": command,
|
||||||
|
"message": f"Error starting tunneld: {e}",
|
||||||
|
}
|
||||||
|
|
||||||
|
case "start-watcher":
|
||||||
|
logger.info("Start tunneld watcher request from %s: %s", sid, data)
|
||||||
|
await self.start_tunnel_watcher()
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command": command,
|
||||||
|
"message": "Tunneld watcher started successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
case "end-watcher":
|
||||||
|
logger.info("End tunneld watcher request from %s: %s", sid, data)
|
||||||
|
await self.end_tunnel_watcher()
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command": command,
|
||||||
|
"message": "Tunneld watcher stopped successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
case "shutdown":
|
||||||
|
logger.info("Shutdown tunneld request from %s: %s", sid, data)
|
||||||
|
try:
|
||||||
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
return {
|
||||||
|
"command_status": "OK",
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command": command,
|
||||||
|
"message": "Server shutting down...",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error shutting down tunneld: %s", e)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Error shutting down tunneld: {e}",
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear":
|
||||||
|
logger.info("Clearing tunnels...")
|
||||||
|
try:
|
||||||
|
self.tunneld_core.clear()
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"message": "Cleared tunnels...",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error clearing tunnels: %s", e)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Error clearing tunnels: {e}",
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cancel":
|
||||||
|
logger.info("Canceling tunnel request from %s: %s", sid, data)
|
||||||
|
try:
|
||||||
|
udid = (
|
||||||
|
data.get("udid")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "udid", self.context.udid)
|
||||||
|
)
|
||||||
|
if udid is None:
|
||||||
|
udid = self.context.udid
|
||||||
|
self.tunneld_core.cancel(udid=udid)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "OK",
|
||||||
|
"udid": udid,
|
||||||
|
"message": f"tunnel {udid} Canceled ...",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error canceling tunnel: %s", e)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Error canceling tunnel: {e}",
|
||||||
|
}
|
||||||
|
|
||||||
|
case _:
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "tunneld_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Unknown operation: {command}",
|
||||||
|
}
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def reverse_geocode(sid, data: LatLng):
|
||||||
|
logger.info("OSM Proxy Request from %s, data: %s", sid, data)
|
||||||
|
return await self.get_reverse_geocode(data)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def favorite_control(sid, data: dict):
|
||||||
|
command = (
|
||||||
|
data.get("command")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "command", None)
|
||||||
|
)
|
||||||
|
logger.info("Favorite Control command: %s requested from %s", command, sid)
|
||||||
|
match command:
|
||||||
|
case "set":
|
||||||
|
cstat, cmessage = await self.set_favorite(data)
|
||||||
|
if cstat == "ERROR":
|
||||||
|
logger.error("Favorite set failed: %s", cmessage)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "favorite_control",
|
||||||
|
"command_status": cstat,
|
||||||
|
"message": cmessage,
|
||||||
|
"data": {
|
||||||
|
"favorites": await self.get_favorites(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear":
|
||||||
|
cstat, cmessage = await self.clear_favorite(data)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "favorite_control",
|
||||||
|
"command_status": cstat,
|
||||||
|
"message": cmessage,
|
||||||
|
"data": {
|
||||||
|
"favorites": await self.get_favorites(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get":
|
||||||
|
favs = await self.get_favorites()
|
||||||
|
if favs:
|
||||||
|
cstat = "OK"
|
||||||
|
cmessage = "Favorites retrieved successfully"
|
||||||
|
else:
|
||||||
|
cstat = "ERROR"
|
||||||
|
cmessage = "No favorites found"
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "favorite_control",
|
||||||
|
"command_status": cstat,
|
||||||
|
"message": cmessage,
|
||||||
|
"data": {
|
||||||
|
"favorites": favs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case _:
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "favorite_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Invalid command: {command}",
|
||||||
|
}
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def category_control(sid, data: dict):
|
||||||
|
command = (
|
||||||
|
data.get("command")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else getattr(data, "command", None)
|
||||||
|
)
|
||||||
|
logger.info("Category Control command: %s requested from %s", command, sid)
|
||||||
|
match command:
|
||||||
|
case "add":
|
||||||
|
cstat, cmessage = await self.add_category(data)
|
||||||
|
if cstat == "ERROR":
|
||||||
|
logger.error("Category set failed: %s", cmessage)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "category_control",
|
||||||
|
"command_status": cstat,
|
||||||
|
"message": cmessage,
|
||||||
|
"data": {
|
||||||
|
"categories": await self.get_categories(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
cstat, cmessage = await self.delete_category(data)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "category_control",
|
||||||
|
"command_status": cstat,
|
||||||
|
"message": cmessage,
|
||||||
|
"data": {
|
||||||
|
"categories": await self.get_categories(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get":
|
||||||
|
cats = await self.get_categories()
|
||||||
|
if cats:
|
||||||
|
cstat = "OK"
|
||||||
|
cmessage = "Categories retrieved successfully"
|
||||||
|
else:
|
||||||
|
cstat = "ERROR"
|
||||||
|
cmessage = "No categories found"
|
||||||
|
logger.info("Categories: %s", cats)
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "category_control",
|
||||||
|
"command_status": cstat,
|
||||||
|
"message": cmessage,
|
||||||
|
"data": {
|
||||||
|
"categories": cats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case _:
|
||||||
|
return {
|
||||||
|
"command": command,
|
||||||
|
"command_class": "category_control",
|
||||||
|
"command_status": "ERROR",
|
||||||
|
"message": f"Invalid command: {command}",
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import socketio
|
||||||
|
|
||||||
|
from .geo_cache import AsyncReverseGeocoder
|
||||||
|
from .icloud_monitor import FindMyMonitor
|
||||||
|
from .models import iCloudLocationData
|
||||||
|
|
||||||
|
|
||||||
|
ACTIVE_SIMULATION_STATES = {"RUNNING", "PAUSED"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_flag(name: str, default: str = "True") -> bool:
|
||||||
|
return os.getenv(name, default).lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
class LocationSimulationState:
|
||||||
|
def __init__(self):
|
||||||
|
self.connected_clients: set[str] = set()
|
||||||
|
self.current_location: Optional[Dict[str, str]] = None
|
||||||
|
self.device_name: Optional[str] = os.getenv("SELECTED_DEVICE_NAME")
|
||||||
|
self.fmf_location: Optional[iCloudLocationData] = None
|
||||||
|
self.fmf_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self.icloud_consumer_task = None
|
||||||
|
self.icloud_monitor = FindMyMonitor(self.fmf_queue)
|
||||||
|
self.icloud_monitor_task = None
|
||||||
|
self.icloud_monitor_enabled: bool = False
|
||||||
|
self.tunnel_watcher_task = None
|
||||||
|
self.next_move: Optional[float] = None
|
||||||
|
self.simulation_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self.simulation_queue_data: Dict = {}
|
||||||
|
self.simulation_queue_order: list[str] = []
|
||||||
|
self.simulation_queue_deleted_items: list[str] = []
|
||||||
|
self.simulation_queue_pending_ids: set[str] = set()
|
||||||
|
self.simulation_queue_state: str = "STOPPED"
|
||||||
|
self.simulation_noise: bool = True
|
||||||
|
self.simulation_task: Optional[asyncio.Task] = None
|
||||||
|
self.sio: socketio.AsyncServer = socketio.AsyncServer(
|
||||||
|
async_mode="asgi", cors_allowed_origins="*"
|
||||||
|
)
|
||||||
|
self.test_mode: bool = env_flag("TEST_MODE", "True")
|
||||||
|
self.tunnel = None
|
||||||
|
self.udid: Optional[str] = None
|
||||||
|
self.reverse_geocode = AsyncReverseGeocoder()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simulation_active(self) -> bool:
|
||||||
|
task_active = (
|
||||||
|
self.simulation_task is not None
|
||||||
|
and not self.simulation_task.done()
|
||||||
|
)
|
||||||
|
return self.simulation_queue_state in ACTIVE_SIMULATION_STATES and task_active
|
||||||
|
|
||||||
|
@simulation_active.setter
|
||||||
|
def simulation_active(self, active: bool) -> None:
|
||||||
|
if active:
|
||||||
|
if self.simulation_queue_state not in ACTIVE_SIMULATION_STATES:
|
||||||
|
self.simulation_queue_state = "RUNNING"
|
||||||
|
elif self.simulation_queue_state != "SHUTDOWN":
|
||||||
|
self.simulation_queue_state = "STOPPED"
|
||||||
|
|
||||||
|
def get_current_loc_id(self) -> Optional[str]:
|
||||||
|
if not isinstance(self.current_location, dict):
|
||||||
|
return None
|
||||||
|
loc_id = self.current_location.get("loc_id")
|
||||||
|
if isinstance(loc_id, str) and loc_id:
|
||||||
|
return loc_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_current_loc_id(self, loc_id: Optional[str]) -> None:
|
||||||
|
if isinstance(loc_id, str) and loc_id:
|
||||||
|
self.current_location = {"loc_id": loc_id}
|
||||||
|
else:
|
||||||
|
self.current_location = None
|
||||||
|
|
||||||
|
def get_current_item(self) -> Optional[dict]:
|
||||||
|
current_loc_id = self.get_current_loc_id()
|
||||||
|
if current_loc_id is None:
|
||||||
|
return None
|
||||||
|
item = self.simulation_queue_data.get(current_loc_id)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
return item
|
||||||
|
return None
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import fastapi
|
||||||
|
from pymobiledevice3.exceptions import (
|
||||||
|
ConnectionFailedError,
|
||||||
|
InvalidServiceError,
|
||||||
|
MuxException,
|
||||||
|
TunneldConnectionError,
|
||||||
|
)
|
||||||
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
|
from pymobiledevice3.remote.common import TunnelProtocol
|
||||||
|
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
|
||||||
|
from pymobiledevice3.remote.tunnel_service import (
|
||||||
|
CoreDeviceTunnelProxy,
|
||||||
|
TunnelResult,
|
||||||
|
create_core_device_tunnel_service_using_rsd,
|
||||||
|
get_remote_pairing_tunnel_services,
|
||||||
|
)
|
||||||
|
from pymobiledevice3.remote.utils import get_rsds
|
||||||
|
from pymobiledevice3.tunneld.server import TunnelTask
|
||||||
|
|
||||||
|
from .json_formatter import logger
|
||||||
|
|
||||||
|
|
||||||
|
class TunnelService:
|
||||||
|
def __init__(self, context, tunneld_core):
|
||||||
|
self.context = context
|
||||||
|
self.tunneld_core = tunneld_core
|
||||||
|
self.simulation_service = None
|
||||||
|
self.icloud_service = None
|
||||||
|
|
||||||
|
def bind_services(self, simulation_service, icloud_service) -> None:
|
||||||
|
self.simulation_service = simulation_service
|
||||||
|
self.icloud_service = icloud_service
|
||||||
|
|
||||||
|
async def get_tun(
|
||||||
|
self,
|
||||||
|
udid: Optional[str] = None,
|
||||||
|
max_retries: int = 10,
|
||||||
|
retry_delay: float = 0.5,
|
||||||
|
) -> RemoteServiceDiscoveryService:
|
||||||
|
"""Resolve an active local tunnel and connect directly to its RSD endpoint."""
|
||||||
|
if self.context.tunnel is not None:
|
||||||
|
return self.context.tunnel
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
candidates = []
|
||||||
|
for active_tunnel in self.tunneld_core.tunnel_tasks.values():
|
||||||
|
if active_tunnel.tunnel is None or active_tunnel.udid is None:
|
||||||
|
continue
|
||||||
|
if udid is None or active_tunnel.udid == udid:
|
||||||
|
candidates.append(active_tunnel.tunnel)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.info(
|
||||||
|
"Waiting for local tunnel to be ready... (attempt %s/%s)",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
continue
|
||||||
|
logger.error("Failed to find an active local tunnel after max retries")
|
||||||
|
raise TunneldConnectionError()
|
||||||
|
|
||||||
|
tunnel = candidates[0]
|
||||||
|
if udid and len(candidates) > 1:
|
||||||
|
logger.warning(
|
||||||
|
"Multiple local tunnels found for udid %s; using first available",
|
||||||
|
udid,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rsd = RemoteServiceDiscoveryService((tunnel.address, tunnel.port))
|
||||||
|
await rsd.connect()
|
||||||
|
logger.info(
|
||||||
|
"Connected to local tunnel at %s:%s after %s retries",
|
||||||
|
tunnel.address,
|
||||||
|
tunnel.port,
|
||||||
|
attempt,
|
||||||
|
)
|
||||||
|
self.context.tunnel = rsd
|
||||||
|
return rsd
|
||||||
|
except Exception:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.info(
|
||||||
|
"Tunnel endpoint not ready yet... (attempt %s/%s)",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to connect to local tunnel endpoint after max retries"
|
||||||
|
)
|
||||||
|
raise TunneldConnectionError()
|
||||||
|
|
||||||
|
raise TunneldConnectionError()
|
||||||
|
|
||||||
|
def collect_active_tunnels(self) -> Dict[str, dict]:
|
||||||
|
active: Dict[str, dict] = {}
|
||||||
|
for interface, tunnel_task in self.tunneld_core.tunnel_tasks.items():
|
||||||
|
if tunnel_task is None or tunnel_task.tunnel is None or tunnel_task.udid is None:
|
||||||
|
continue
|
||||||
|
tunnel = tunnel_task.tunnel
|
||||||
|
active[str(interface)] = {
|
||||||
|
"interface": str(interface),
|
||||||
|
"udid": tunnel_task.udid,
|
||||||
|
"address": getattr(tunnel, "address", None),
|
||||||
|
"port": getattr(tunnel, "port", None),
|
||||||
|
"transport": getattr(tunnel, "protocol", None),
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
|
||||||
|
async def handle_tunnel_drop(self, disconnected: list[dict]) -> None:
|
||||||
|
disconnected_udids: set[str] = set()
|
||||||
|
for item in disconnected:
|
||||||
|
udid = item.get("udid")
|
||||||
|
if isinstance(udid, str) and udid:
|
||||||
|
disconnected_udids.add(udid)
|
||||||
|
if not disconnected_udids:
|
||||||
|
return
|
||||||
|
current_active = self.collect_active_tunnels()
|
||||||
|
active_udids: set[str] = set()
|
||||||
|
for item in current_active.values():
|
||||||
|
udid = item.get("udid")
|
||||||
|
if isinstance(udid, str) and udid:
|
||||||
|
active_udids.add(udid)
|
||||||
|
primary_disconnected = (
|
||||||
|
self.context.udid in disconnected_udids if self.context.udid else False
|
||||||
|
)
|
||||||
|
no_tunnels_left = len(current_active) == 0
|
||||||
|
if not primary_disconnected and not no_tunnels_left:
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"Tunnel drop detected. primary_udid=%s disconnected_udids=%s active_udids=%s",
|
||||||
|
self.context.udid,
|
||||||
|
sorted(disconnected_udids),
|
||||||
|
sorted(active_udids),
|
||||||
|
)
|
||||||
|
if self.context.tunnel is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self.context.tunnel.close()
|
||||||
|
self.context.tunnel = None
|
||||||
|
if no_tunnels_left:
|
||||||
|
self.context.udid = None
|
||||||
|
if self.context.simulation_active and self.simulation_service is not None:
|
||||||
|
await self.simulation_service.pause_queue()
|
||||||
|
await self.context.sio.emit(
|
||||||
|
"appError",
|
||||||
|
{
|
||||||
|
"type": "tunnel_disconnected",
|
||||||
|
"message": "Tunnel dropped. Simulation paused until reconnection.",
|
||||||
|
"disconnected_udids": sorted(disconnected_udids),
|
||||||
|
},
|
||||||
|
namespace="/",
|
||||||
|
)
|
||||||
|
if self.icloud_service is not None:
|
||||||
|
await self.icloud_service.stop()
|
||||||
|
|
||||||
|
async def tunnel_watcher_loop(self) -> None:
|
||||||
|
previous = self.collect_active_tunnels()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
current = self.collect_active_tunnels()
|
||||||
|
added_keys = set(current.keys()) - set(previous.keys())
|
||||||
|
removed_keys = set(previous.keys()) - set(current.keys())
|
||||||
|
added = [current[k] for k in sorted(added_keys)]
|
||||||
|
removed = [previous[k] for k in sorted(removed_keys)]
|
||||||
|
for item in added:
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Tunnel discovered interface=%s udid=%s address=%s port=%s transport=%s",
|
||||||
|
item.get("interface"),
|
||||||
|
item.get("udid"),
|
||||||
|
item.get("address"),
|
||||||
|
item.get("port"),
|
||||||
|
item.get("transport"),
|
||||||
|
)
|
||||||
|
await self.safe_sio_emit("tunnel_device_connected", item)
|
||||||
|
logger.info(
|
||||||
|
"Current udid: %s, new udid: %s",
|
||||||
|
self.context.udid,
|
||||||
|
item.get("udid"),
|
||||||
|
)
|
||||||
|
if self.context.udid is None:
|
||||||
|
self.context.udid = item.get("udid")
|
||||||
|
device_name = await self.get_device_name()
|
||||||
|
selected_device_name = self.context.device_name
|
||||||
|
if selected_device_name and device_name != selected_device_name:
|
||||||
|
wrong_udid = self.context.udid
|
||||||
|
self.tunneld_core.cancel(udid=item.get("udid"))
|
||||||
|
logger.warning(
|
||||||
|
"Tunnel established to wrong device. Dropping tunnel. wrong_udid=%s for device: %s",
|
||||||
|
wrong_udid,
|
||||||
|
selected_device_name,
|
||||||
|
)
|
||||||
|
self.context.udid = None
|
||||||
|
await self.safe_sio_emit(
|
||||||
|
"appError",
|
||||||
|
{
|
||||||
|
"type": "tunnel_wrong_device",
|
||||||
|
"message": "Tunnel established to wrong device. Dropping tunnel.",
|
||||||
|
"wrong_udid": wrong_udid,
|
||||||
|
"device_name": device_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.get_tun(
|
||||||
|
item.get("udid"), max_retries=10, retry_delay=1.0
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Tunnel watcher failed while handling added tunnel for udid=%s",
|
||||||
|
item.get("udid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
for item in removed:
|
||||||
|
try:
|
||||||
|
logger.warning(
|
||||||
|
"Tunnel disconnected interface=%s udid=%s address=%s port=%s",
|
||||||
|
item.get("interface"),
|
||||||
|
item.get("udid"),
|
||||||
|
item.get("address"),
|
||||||
|
item.get("port"),
|
||||||
|
)
|
||||||
|
await self.safe_sio_emit(
|
||||||
|
"tunnel_device_disconnected", item
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Tunnel watcher failed while emitting disconnection for udid=%s",
|
||||||
|
item.get("udid"),
|
||||||
|
)
|
||||||
|
await self.handle_tunnel_drop(removed)
|
||||||
|
|
||||||
|
previous = current
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Tunnel watcher loop crashed; continuing")
|
||||||
|
|
||||||
|
async def start_watcher(self) -> None:
|
||||||
|
if (
|
||||||
|
self.context.tunnel_watcher_task is None
|
||||||
|
or self.context.tunnel_watcher_task.done()
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
self.context.tunnel_watcher_task is not None
|
||||||
|
and self.context.tunnel_watcher_task.done()
|
||||||
|
):
|
||||||
|
with suppress(Exception):
|
||||||
|
exc = self.context.tunnel_watcher_task.exception()
|
||||||
|
if exc is not None:
|
||||||
|
logger.error(
|
||||||
|
"Previous tunnel watcher task exited with error: %s", exc
|
||||||
|
)
|
||||||
|
self.context.tunnel_watcher_task = asyncio.create_task(
|
||||||
|
self.tunnel_watcher_loop(),
|
||||||
|
name="tunnel-task-watcher",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_watcher(self) -> None:
|
||||||
|
if self.context.tunnel_watcher_task is not None:
|
||||||
|
self.context.tunnel_watcher_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await self.context.tunnel_watcher_task
|
||||||
|
self.context.tunnel_watcher_task = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_data(d):
|
||||||
|
mydict = {}
|
||||||
|
for key, value in d.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
TunnelService.parse_data(value)
|
||||||
|
elif isinstance(value, (str, int, float, bool, list)):
|
||||||
|
mydict[key] = value
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
mydict[key] = "BYTE DATA"
|
||||||
|
else:
|
||||||
|
mydict[key] = value
|
||||||
|
return mydict
|
||||||
|
|
||||||
|
async def get_device_name(self):
|
||||||
|
if self.context.tunnel is None:
|
||||||
|
await self.get_tun()
|
||||||
|
device_name = await self.context.tunnel.get_value(key="DeviceName")
|
||||||
|
return device_name
|
||||||
|
|
||||||
|
async def safe_sio_emit(
|
||||||
|
self, event: str, payload: dict, timeout_seconds: float = 2.0
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self.context.sio.emit(event, payload, namespace="/"),
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning("Socket.IO emit timed out for event=%s", event)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Socket.IO emit failed for event=%s", event)
|
||||||
|
|
||||||
|
async def device_reboot(self, delay):
|
||||||
|
logger.info("Reboot initiated with delay %s", delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
os.system("shutdown -r now")
|
||||||
|
|
||||||
|
async def device_shutdown(self, delay):
|
||||||
|
logger.info("Shutdown initiated with delay %s", delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
os.system("shutdown -h now")
|
||||||
|
|
||||||
|
async def device_info(self):
|
||||||
|
tunnels = {}
|
||||||
|
for _, active_tunnel in self.tunneld_core.tunnel_tasks.items():
|
||||||
|
if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
|
||||||
|
continue
|
||||||
|
if active_tunnel.udid not in tunnels:
|
||||||
|
tunnels[active_tunnel.udid] = {}
|
||||||
|
try:
|
||||||
|
lockdown = await create_using_usbmux(
|
||||||
|
serial=active_tunnel.udid, autopair=False
|
||||||
|
)
|
||||||
|
tunnels[active_tunnel.udid] = self.parse_data(lockdown.all_values)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to create lockdown session for device %s: %s",
|
||||||
|
active_tunnel.udid,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return tunnels
|
||||||
|
|
||||||
|
def list_tunnels(self) -> dict[str, list[dict]]:
|
||||||
|
tunnels = {}
|
||||||
|
for ip, active_tunnel in self.tunneld_core.tunnel_tasks.items():
|
||||||
|
if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
|
||||||
|
continue
|
||||||
|
if active_tunnel.udid not in tunnels:
|
||||||
|
tunnels[active_tunnel.udid] = []
|
||||||
|
tunnels[active_tunnel.udid].append(
|
||||||
|
{
|
||||||
|
"tunnel-address": active_tunnel.tunnel.address,
|
||||||
|
"tunnel-port": active_tunnel.tunnel.port,
|
||||||
|
"interface": ip,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tunnels
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def shutdown_process_response() -> dict:
|
||||||
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
return {
|
||||||
|
"operation": "shutdown",
|
||||||
|
"data": True,
|
||||||
|
"message": "Server shutting down...",
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_tunnels_response(self) -> dict:
|
||||||
|
self.tunneld_core.clear()
|
||||||
|
return {
|
||||||
|
"operation": "clear_tunnels",
|
||||||
|
"data": True,
|
||||||
|
"message": "Cleared tunnels...",
|
||||||
|
}
|
||||||
|
|
||||||
|
def cancel_tunnel_response(self, udid: str) -> dict:
|
||||||
|
self.tunneld_core.cancel(udid=udid)
|
||||||
|
return {
|
||||||
|
"operation": "cancel",
|
||||||
|
"udid": udid,
|
||||||
|
"data": True,
|
||||||
|
"message": f"tunnel {udid} Canceled ...",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start_tunnel_response(
|
||||||
|
self,
|
||||||
|
udid: Optional[str] = None,
|
||||||
|
ip: Optional[str] = None,
|
||||||
|
connection_type: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if udid is None:
|
||||||
|
udid = self.context.udid
|
||||||
|
udid_tunnels = [
|
||||||
|
t.tunnel
|
||||||
|
for t in self.tunneld_core.tunnel_tasks.values()
|
||||||
|
if t.udid == udid and t.tunnel is not None
|
||||||
|
]
|
||||||
|
if len(udid_tunnels) > 0:
|
||||||
|
self.context.udid = udid
|
||||||
|
return {
|
||||||
|
"interface": udid_tunnels[0].interface,
|
||||||
|
"port": udid_tunnels[0].port,
|
||||||
|
"address": udid_tunnels[0].address,
|
||||||
|
}
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
created_task = False
|
||||||
|
try:
|
||||||
|
if not created_task and connection_type in ("usbmux", None):
|
||||||
|
task_identifier = f"usbmux-{udid}"
|
||||||
|
try:
|
||||||
|
async with await create_using_usbmux(udid) as lockdown:
|
||||||
|
service = await CoreDeviceTunnelProxy.create(lockdown)
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self.tunneld_core.start_tunnel_task(
|
||||||
|
task_identifier,
|
||||||
|
service,
|
||||||
|
protocol=TunnelProtocol.TCP,
|
||||||
|
queue=queue,
|
||||||
|
),
|
||||||
|
name=f"start-tunnel-task-{task_identifier}",
|
||||||
|
)
|
||||||
|
self.tunneld_core.tunnel_tasks[task_identifier] = TunnelTask(
|
||||||
|
task=task, udid=udid
|
||||||
|
)
|
||||||
|
created_task = True
|
||||||
|
except (ConnectionFailedError, InvalidServiceError, MuxException):
|
||||||
|
pass
|
||||||
|
if connection_type in ("usb", None):
|
||||||
|
for rsd in await get_rsds(udid=udid):
|
||||||
|
rsd_ip = rsd.service.address[0]
|
||||||
|
if ip is not None and rsd_ip != ip:
|
||||||
|
await rsd.close()
|
||||||
|
continue
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self.tunneld_core.start_tunnel_task(
|
||||||
|
rsd_ip,
|
||||||
|
await create_core_device_tunnel_service_using_rsd(rsd),
|
||||||
|
queue=queue,
|
||||||
|
),
|
||||||
|
name=f"start-tunnel-usb-{rsd_ip}",
|
||||||
|
)
|
||||||
|
self.tunneld_core.tunnel_tasks[rsd_ip] = TunnelTask(
|
||||||
|
task=task, udid=rsd.udid
|
||||||
|
)
|
||||||
|
created_task = True
|
||||||
|
if not created_task and connection_type in ("wifi", None):
|
||||||
|
for remotepairing in await get_remote_pairing_tunnel_services(
|
||||||
|
udid=udid
|
||||||
|
):
|
||||||
|
remotepairing_ip = remotepairing.hostname
|
||||||
|
if ip is not None and remotepairing_ip != ip:
|
||||||
|
await remotepairing.close()
|
||||||
|
continue
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self.tunneld_core.start_tunnel_task(
|
||||||
|
remotepairing_ip, remotepairing, queue=queue
|
||||||
|
),
|
||||||
|
name=f"start-tunnel-wifi-{remotepairing_ip}",
|
||||||
|
)
|
||||||
|
self.tunneld_core.tunnel_tasks[remotepairing_ip] = TunnelTask(
|
||||||
|
task=task, udid=remotepairing.remote_identifier
|
||||||
|
)
|
||||||
|
created_task = True
|
||||||
|
except Exception as e:
|
||||||
|
return fastapi.Response(
|
||||||
|
status_code=501,
|
||||||
|
content=json.dumps(
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"exception": e.__class__.__name__,
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not created_task:
|
||||||
|
return fastapi.Response(
|
||||||
|
status_code=501, content=json.dumps({"error": "task not created"})
|
||||||
|
)
|
||||||
|
tunnel: Optional[TunnelResult] = await queue.get()
|
||||||
|
if tunnel is not None:
|
||||||
|
self.context.udid = udid
|
||||||
|
return {
|
||||||
|
"interface": tunnel.interface,
|
||||||
|
"port": tunnel.port,
|
||||||
|
"address": tunnel.address,
|
||||||
|
}
|
||||||
|
return fastapi.Response(
|
||||||
|
status_code=404,
|
||||||
|
content=json.dumps(
|
||||||
|
{"error": "something went wrong during tunnel creation"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def restart_response(self) -> dict:
|
||||||
|
self.tunneld_core.clear()
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
self.tunneld_core.start()
|
||||||
|
return {
|
||||||
|
"operation": "restart-tunneld",
|
||||||
|
"data": True,
|
||||||
|
"message": "Restarting tunneld...",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _app_restart(self) -> None:
|
||||||
|
logger.info("Restarting application...")
|
||||||
|
await self.stop_watcher()
|
||||||
|
await self.icloud_service.stop()
|
||||||
|
await self.simulation_service.empty_queue()
|
||||||
|
await self.tunneld_core.close()
|
||||||
|
await self.context.sio.shutdown()
|
||||||
|
try:
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
max_fds = os.sysconf("SC_OPEN_MAX")
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
max_fds = 1024
|
||||||
|
|
||||||
|
os.closerange(3, max_fds)
|
||||||
|
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||||
@@ -1356,6 +1356,7 @@ dependencies = [
|
|||||||
{ name = "sqlalchemy-orm" },
|
{ name = "sqlalchemy-orm" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
{ name = "typing" },
|
{ name = "typing" },
|
||||||
|
{ name = "uptime" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1376,6 +1377,7 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy-orm", specifier = ">=1.2.10" },
|
{ name = "sqlalchemy-orm", specifier = ">=1.2.10" },
|
||||||
{ name = "typer", specifier = ">=0.24.1" },
|
{ name = "typer", specifier = ">=0.24.1" },
|
||||||
{ name = "typing", specifier = "==3.10.0.0" },
|
{ name = "typing", specifier = "==3.10.0.0" },
|
||||||
|
{ name = "uptime", specifier = ">=3.0.1" },
|
||||||
{ name = "uvicorn", specifier = "==0.41.0" },
|
{ name = "uvicorn", specifier = "==0.41.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1893,6 +1895,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uptime"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ad/53/6c420ddf6949097d6f9406358951c9322505849bea9cb79efe3acc0bb55d/uptime-3.0.1.tar.gz", hash = "sha256:7c300254775b807ce46e3dcbcda30aa3b9a204b9c57a7ac1e79ee6dbe3942973", size = 6630, upload-time = "2013-10-07T14:19:58.456Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user