milestones
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
__pycache__/*
|
||||
*.pyc
|
||||
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$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<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>
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<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",
|
||||
"typer>=0.24.1",
|
||||
"typing==3.10.0.0",
|
||||
"uptime>=3.0.1",
|
||||
"uvicorn==0.41.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,25 +2,27 @@ import logging
|
||||
import sqlite3
|
||||
import json
|
||||
from geopy.geocoders import Nominatim
|
||||
from geopy.distance import geodesic
|
||||
from geopy.adapters import AioHTTPAdapter
|
||||
from geopy.extra.rate_limiter import AsyncRateLimiter
|
||||
|
||||
logger = logging.getLogger("ios-api")
|
||||
CACHE_LOOKUP_SQL = "SELECT address, favorite FROM location_cache WHERE lat_lon = ?"
|
||||
CACHE_UPSERT_SQL = """
|
||||
INSERT INTO location_cache (lat_lon, address)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address
|
||||
"""
|
||||
FAVORITE_LOOKUP_SQL = "SELECT favorite FROM location_cache WHERE favorite IS NOT NULL"
|
||||
FAVORITE_UPSERT_SQL = """
|
||||
INSERT INTO location_cache (lat_lon, favorite)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(lat_lon) DO UPDATE SET favorite = excluded.favorite
|
||||
"""
|
||||
FAVORITE_CLEAR_SQL = "UPDATE location_cache SET favorite = NULL WHERE lat_lon = ?"
|
||||
from .models import Favorite
|
||||
|
||||
|
||||
logger = logging.getLogger("ios-api")
|
||||
CACHE_LOOKUP_SQL = "SELECT l.address AS address, f.favorite as favorite, f.category_id as category_id FROM location_cache l LEFT JOIN favorites f ON l.favorite_id = f.favorite_id WHERE l.lat_lon = ?"
|
||||
CACHE_UPSERT_SQL = "INSERT INTO location_cache (lat_lon, address) VALUES (?, ?) ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address"
|
||||
CATEGORY_LOOKUP_SQL = "SELECT c.category_id, c.name, c.icon, c.color, GROUP_CONCAT(f.favorite_id, ', ') AS favorite_list FROM categories c LEFT JOIN favorites f ON c.category_id = f.category_id GROUP BY c.category_id WHERE category_id = ?"
|
||||
CATEGORIES_LOOKUP_SQL = "SELECT c.category_id, c.name, c.icon, c.color, GROUP_CONCAT(f.favorite_id, ', ') AS favorite_list FROM categories c LEFT JOIN favorites f ON c.category_id = f.category_id GROUP BY c.category_id"
|
||||
CATEGORY_CLEAR_SQL = "DELETE FROM categories WHERE category_id = ?"
|
||||
CATEGORY_UPSERT_SQL = "INSERT INTO categories (name, icon, color) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET icon = excluded.icon, color = excluded.color"
|
||||
FAVORITES_LOOKUP_SQL = "SELECT f.favorite_id, f.favorite, f.category_id from favorites f"
|
||||
FAVORITE_LOOKUP_SQL = "SELECT f.favorite, f.category_id from favorites f WHERE f.favorite_id = ?"
|
||||
FAVORITE_CLEAR_SQL = "DELETE FROM favorites WHERE favorite_id = ?"
|
||||
FAVORITE_UPSERT_SQL = "INSERT INTO favorites (name, favorite, category_id) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET favorite = excluded.favorite, category_id = excluded.category_id"
|
||||
FAVORITE_CACHE_UPSERT_SQL = "INSERT INTO location_cache (lat_lon, favorite_id) VALUES (?, ?) ON CONFLICT(lat_lon) DO UPDATE SET favorite_id = excluded.favorite_id"
|
||||
FAVORITE_CACHE_ALL_INSERT_SQL = "INSERT INTO location_cache (lat_lon, address, favorite_id) VALUES (?, ?, ?) ON CONFLICT(lat_lon) DO UPDATE SET address = excluded.address, favorite_id = excluded.favorite_id"
|
||||
|
||||
class AsyncReverseGeocoder:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -35,89 +37,167 @@ class AsyncReverseGeocoder:
|
||||
"""Initializes the SQLite database."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
category_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
icon TEXT,
|
||||
color TEXT
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
favorite_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
favorite TEXT,
|
||||
FOREIGN KEY (category_id) REFERENCES categories (category_id)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE SET NULL
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS location_cache (
|
||||
lat_lon TEXT PRIMARY KEY,
|
||||
address TEXT,
|
||||
favorite TEXT
|
||||
|
||||
FOREIGN KEY (favorite_id) REFERENCES favorites (favorite_id)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE SET NULL
|
||||
)
|
||||
''')
|
||||
cursor.execute("PRAGMA table_info(location_cache)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
if "favorite" not in columns:
|
||||
cursor.execute("ALTER TABLE location_cache ADD COLUMN favorite TEXT")
|
||||
if "favorite" in columns:
|
||||
cursor.execute("ALTER TABLE location_cache DROP COLUMN favorite")
|
||||
if "favorite_id" not in columns:
|
||||
cursor.execute('''
|
||||
ALTER TABLE location_cache
|
||||
ADD COLUMN favorite_id INTEGER REFERENCES favorites (favorite_id)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE SET NULL
|
||||
''')
|
||||
cursor.execute("PRAGMA table_info(favorites)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
if "category_id" not in columns:
|
||||
cursor.execute('''
|
||||
ALTER TABLE favorites
|
||||
ADD COLUMN category_id INTEGER REFERENCES categories (category_id)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE SET NULL
|
||||
''')
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(lat: float, lon: float) -> str:
|
||||
return f"{lat:.5f},{lon:.5f}"
|
||||
|
||||
@staticmethod
|
||||
def trace_callback(sql):
|
||||
logger.info("SQL: %s", sql)
|
||||
|
||||
def _read_cached_address(self, key: str) -> dict | None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.set_trace_callback(self.trace_callback)
|
||||
cursor = conn.execute(CACHE_LOOKUP_SQL, (key,))
|
||||
row = cursor.fetchone()
|
||||
cached = {}
|
||||
if row:
|
||||
cached['address'] = json.loads(row[0]) if row[0] else {}
|
||||
cached['favorite'] = json.loads(row[1]) if row[1] else {}
|
||||
address = json.loads(row[0]) if row[0] else {}
|
||||
favorite = json.loads(row[1]) if row[1] else {}
|
||||
favorite['category_id'] = row[2] if row[2] else None
|
||||
cached['address'] = address
|
||||
cached['favorite'] = favorite
|
||||
return cached if row else None
|
||||
|
||||
def _read_favorite(self, favorite_id: int) -> dict | None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
cursor = conn.execute(FAVORITE_LOOKUP_SQL, (favorite_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
for row_id, json_str, cat_id in row:
|
||||
favorite = json.loads(json_str)
|
||||
favorite['id'] = row_id
|
||||
favorite['category_id'] = cat_id
|
||||
return favorite if favorite else None
|
||||
|
||||
def _read_favorites(self) -> list | None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
cursor = conn.execute(FAVORITES_LOOKUP_SQL)
|
||||
rows = cursor.fetchall()
|
||||
favorites = []
|
||||
if rows:
|
||||
for row_id, json_str, cat_id in rows:
|
||||
data = json.loads(json_str)
|
||||
data['id'] = row_id
|
||||
data['category_id'] = cat_id
|
||||
favorites.append(data)
|
||||
return favorites if favorites else None
|
||||
|
||||
def _read_category(self, category_id: int) -> dict | None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
cursor = conn.execute(CATEGORY_LOOKUP_SQL, (category_id,))
|
||||
row = cursor.fetchone()
|
||||
return row[1] if row else None
|
||||
|
||||
def _read_categories(self) -> list | None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
cursor = conn.execute(CATEGORIES_LOOKUP_SQL)
|
||||
rows = cursor.fetchall()
|
||||
categories = []
|
||||
if rows:
|
||||
for row_id, name, icon, color, favs in rows:
|
||||
fav_list = favs.split(',') if favs else []
|
||||
data = {'id': row_id, 'name': name, 'icon': icon, 'color': color, 'favorite_list': fav_list}
|
||||
categories.append(data)
|
||||
return categories if categories else None
|
||||
|
||||
def _store_cached_address(self, key: str, address_data: dict) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(CACHE_UPSERT_SQL, (key, json.dumps(address_data)))
|
||||
conn.commit()
|
||||
|
||||
def _read_favorites(self) -> list | None:
|
||||
def _set_favorite(self, key: str, favorite_id: int) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(FAVORITE_LOOKUP_SQL)
|
||||
rows = cursor.fetchall()
|
||||
favorites = [json.loads(row[0]) for row in rows if row[0]]
|
||||
return favorites if favorites else None
|
||||
|
||||
async def clear_favorite(self, lat: float, lon: float) -> bool:
|
||||
key = self._cache_key(lat, lon)
|
||||
cached = self._read_cached_address(key)
|
||||
if cached and cached.get("favorite"):
|
||||
favorite = cached.get("favorite")
|
||||
name = favorite.get("name") if isinstance(favorite, dict) else key
|
||||
logger.info("Clearing favorite %s", name)
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(FAVORITE_CLEAR_SQL, (key,))
|
||||
conn.execute(FAVORITE_CACHE_UPSERT_SQL, (key, favorite_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
else:
|
||||
return False
|
||||
|
||||
async def set_favorite(self, lat: float, lon: float, favorite_data: dict) -> bool:
|
||||
key = self._cache_key(lat, lon)
|
||||
name = favorite_data.get("name")
|
||||
if not name:
|
||||
return False
|
||||
logger.info("Setting favorite location %s to location_cache for %s", name, key)
|
||||
try:
|
||||
def _store_favorite(self, key: str, name: str, category_id: int, favorite_data: Favorite) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(FAVORITE_UPSERT_SQL, (key, json.dumps(favorite_data)))
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
cursor = conn.execute(FAVORITE_UPSERT_SQL, (name, json.dumps(favorite_data), category_id))
|
||||
favorite_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
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
|
||||
if favorite_id is not None:
|
||||
self._set_favorite(key, favorite_id)
|
||||
|
||||
async def get_favorites(self) -> list | None:
|
||||
"""Reverse geocode with SQLite cache."""
|
||||
logger.info("Checking location_cache for favorites")
|
||||
favorites = self._read_favorites()
|
||||
return favorites
|
||||
def _store_category(self, name: str, icon: str, color: str) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute(CATEGORY_UPSERT_SQL, (name, icon, color))
|
||||
conn.commit()
|
||||
|
||||
async def get_address(self, lat: float, lon: float) -> dict | None:
|
||||
"""Reverse geocode with SQLite cache."""
|
||||
key = self._cache_key(lat, lon)
|
||||
cached_address = self._read_cached_address(key)
|
||||
if cached_address is not None:
|
||||
logger.info("Found cached address for %s", key)
|
||||
return cached_address
|
||||
def _store_cached_address_with_favorite(self, key: str, address_data: dict, favorite_id: int) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(FAVORITE_CACHE_ALL_INSERT_SQL, (key, json.dumps(address_data), favorite_id))
|
||||
conn.commit()
|
||||
|
||||
def _clear_favorite(self, favorite_id: int) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute(FAVORITE_CLEAR_SQL, (favorite_id,))
|
||||
conn.commit()
|
||||
|
||||
def _delete_category(self, category_id: int) -> None:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute(CATEGORY_CLEAR_SQL, (category_id,))
|
||||
|
||||
async def _lookup_address(self, key: str) -> dict | None:
|
||||
async with Nominatim(
|
||||
user_agent=self.user_agent,
|
||||
adapter_factory=AioHTTPAdapter
|
||||
@@ -127,12 +207,98 @@ class AsyncReverseGeocoder:
|
||||
geolocator.reverse, min_delay_seconds=1)
|
||||
location = await reverse(key)
|
||||
if location:
|
||||
response = {}
|
||||
logger.info("Nominatim response: %s", location)
|
||||
response['address'] = location.raw.get("address", {})
|
||||
self._store_cached_address(key, response['address'])
|
||||
return response
|
||||
return location.raw.get("address", {})
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("Reverse geocoding failed for key=%s", key)
|
||||
return None
|
||||
|
||||
def get_favorites(self) -> list | None:
|
||||
logger.info("Checking location_cache for favorites")
|
||||
return self._read_favorites()
|
||||
|
||||
def get_categories(self) -> list | None:
|
||||
logger.info("Checking location_cache for categories")
|
||||
return self._read_categories()
|
||||
|
||||
def clear_favorite(self, favorite_id: int) -> bool:
|
||||
favorite = self._read_favorite(favorite_id)
|
||||
if favorite:
|
||||
favorite_name = favorite.get("name") if isinstance(favorite, dict) else favorite_id
|
||||
logger.info("Clearing favorite %s", favorite_name)
|
||||
self._clear_favorite(favorite_id)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def delete_category(self, category_id: int) -> bool:
|
||||
category = self._read_category(category_id)
|
||||
if category:
|
||||
category_name = category.get("name") if isinstance(category, dict) else category_id
|
||||
logger.info("Clearing category %s", category_name)
|
||||
self._delete_category(category_id)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def add_favorite(self, lat: float, lon: float, category_id: int, favorite_data: Favorite) -> bool:
|
||||
key = self._cache_key(lat, lon)
|
||||
name = favorite_data.get("name")
|
||||
if not name:
|
||||
return False
|
||||
logger.info("Adding favorite location %s to favorite database for %s", name, key)
|
||||
self._store_favorite(key, name, category_id, favorite_data)
|
||||
return True
|
||||
|
||||
def add_category(self, name: str, icon: str, color: str) -> bool:
|
||||
logger.info("Adding category %s to category database", name)
|
||||
self._store_category(name, icon, color)
|
||||
return True
|
||||
|
||||
async def get_address(self, lat: float, lon: float) -> dict | None:
|
||||
key = self._cache_key(lat, lon)
|
||||
logger.info("Checking location_cache for %s", key)
|
||||
cached_address = self._read_cached_address(key)
|
||||
response = {}
|
||||
if cached_address:
|
||||
response['address'] = cached_address.get("address")
|
||||
else:
|
||||
address = await self._lookup_address(key)
|
||||
if address is not None:
|
||||
response['address'] = address
|
||||
self._store_cached_address(key, response['address'])
|
||||
else:
|
||||
logger.error("No address found for %s", key)
|
||||
return None
|
||||
|
||||
if cached_address and cached_address.get("favorite"):
|
||||
favorite = cached_address.get("favorite")
|
||||
name = favorite.get("name") if isinstance(favorite, dict) else key
|
||||
logger.info("Found favorite: %s", name)
|
||||
response['favorite'] = favorite
|
||||
else:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
cursor = conn.execute(FAVORITES_LOOKUP_SQL)
|
||||
results = cursor.fetchall()
|
||||
|
||||
for row in results:
|
||||
favorite_id = row[0]
|
||||
favorite_data = json.loads(row[1])
|
||||
favorite_data['id'] = favorite_id
|
||||
|
||||
favorite_coords = (favorite_data.get("latitude"), favorite_data.get("longitude"))
|
||||
radius = favorite_data.get("radius")
|
||||
|
||||
distance = geodesic((lat, lon), favorite_coords).meters
|
||||
logger.info("Distance from %s to favorite %s: %s meters", key, favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown", distance)
|
||||
if distance <= radius:
|
||||
response['favorite'] = favorite_data
|
||||
logger.info("Coordinatees are within favorite radius: %s, setting favorite", favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown")
|
||||
self._set_favorite(key, favorite_id)
|
||||
break
|
||||
else:
|
||||
logger.info("Coordinates are not within favorite radius, favorite: %s", favorite_data.get("name") if isinstance(favorite_data, dict) else "Unknown")
|
||||
response['favorite'] = None
|
||||
return response
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
class FindMyMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -59,9 +60,7 @@ class FindMyMonitor:
|
||||
self.sio = sio
|
||||
self.get_client_sids = get_client_sids
|
||||
self.code_timeout_seconds = code_timeout_seconds
|
||||
self.auth_init_timeout_seconds = int(
|
||||
os.getenv(ENV_AUTH_INIT_TIMEOUT, "0")
|
||||
)
|
||||
self.auth_init_timeout_seconds = int(os.getenv(ENV_AUTH_INIT_TIMEOUT, "0"))
|
||||
self._logged_candidates = False
|
||||
self._no_location_streak = 0
|
||||
self._auth_error_streak = 0
|
||||
@@ -122,7 +121,9 @@ class FindMyMonitor:
|
||||
async def authenticate(self) -> bool:
|
||||
"""Authenticates with iCloud, handling 2FA and token storage."""
|
||||
if not self.username:
|
||||
logger.warning("APPLE_ID is not configured; skipping iCloud monitor authentication")
|
||||
logger.warning(
|
||||
"APPLE_ID is not configured; skipping iCloud monitor authentication"
|
||||
)
|
||||
return False
|
||||
has_token = os.path.exists(self.token_file)
|
||||
if not has_token and not self.password:
|
||||
@@ -140,7 +141,9 @@ class FindMyMonitor:
|
||||
|
||||
if self.api.requires_2fa:
|
||||
logger.info("Two-factor authentication required.")
|
||||
code = await self._request_code_from_vue("Enter the 6-digit Apple verification code")
|
||||
code = await self._request_code_from_vue(
|
||||
"Enter the 6-digit Apple verification code"
|
||||
)
|
||||
if code is None:
|
||||
if sys.stdin and sys.stdin.isatty():
|
||||
code = str(
|
||||
@@ -165,10 +168,12 @@ class FindMyMonitor:
|
||||
await asyncio.to_thread(self.api.trust_session)
|
||||
|
||||
if self.api.requires_2sa:
|
||||
logger.info(textwrap.dedent("""
|
||||
logger.info(
|
||||
textwrap.dedent("""
|
||||
Two-step authentication required.
|
||||
Please select a device to receive a SMS verification code:
|
||||
"""))
|
||||
""")
|
||||
)
|
||||
for i, device in enumerate(self.api.trusted_devices):
|
||||
logger.info(
|
||||
" %s: %s (%s)",
|
||||
@@ -182,14 +187,23 @@ class FindMyMonitor:
|
||||
"2SA required but no interactive terminal is available; deferring authentication"
|
||||
)
|
||||
return False
|
||||
device_index = await asyncio.to_thread(click.prompt, "Please select a device number", type=int) - 1
|
||||
device_index = (
|
||||
await asyncio.to_thread(
|
||||
click.prompt, "Please select a device number", type=int
|
||||
)
|
||||
- 1
|
||||
)
|
||||
device = self.api.trusted_devices[device_index]
|
||||
if not await asyncio.to_thread(self.api.send_verification_code, device):
|
||||
logger.warning("Failed to send verification code")
|
||||
return False
|
||||
|
||||
code = await asyncio.to_thread(click.prompt, "Please enter verification code", type=int)
|
||||
if not await asyncio.to_thread(self.api.validate_verification_code, device, code):
|
||||
code = await asyncio.to_thread(
|
||||
click.prompt, "Please enter verification code", type=int
|
||||
)
|
||||
if not await asyncio.to_thread(
|
||||
self.api.validate_verification_code, device, code
|
||||
):
|
||||
logger.warning("Failed to verify verification code")
|
||||
return False
|
||||
|
||||
@@ -223,15 +237,25 @@ class FindMyMonitor:
|
||||
# Select by ID first (exact), then by name, then first device if no selectors set.
|
||||
self.selected_device = None
|
||||
for device in devices_manager:
|
||||
if self.selected_device_id and device.data.get("id") == self.selected_device_id:
|
||||
if (
|
||||
self.selected_device_id
|
||||
and device.data.get("id") == self.selected_device_id
|
||||
):
|
||||
self.selected_device = device
|
||||
break
|
||||
if self.selected_device is None:
|
||||
for device in devices_manager:
|
||||
if self.selected_device_name and device.name == self.selected_device_name:
|
||||
if (
|
||||
self.selected_device_name
|
||||
and device.name == self.selected_device_name
|
||||
):
|
||||
self.selected_device = device
|
||||
break
|
||||
if self.selected_device is None and not self.selected_device_id and not self.selected_device_name:
|
||||
if (
|
||||
self.selected_device is None
|
||||
and not self.selected_device_id
|
||||
and not self.selected_device_name
|
||||
):
|
||||
for device in devices_manager:
|
||||
self.selected_device = device
|
||||
break
|
||||
@@ -247,20 +271,22 @@ class FindMyMonitor:
|
||||
)
|
||||
if location:
|
||||
data = {
|
||||
"latitude": location['latitude'],
|
||||
"longitude": location['longitude'],
|
||||
"timeStamp": location['timeStamp'],
|
||||
"altitude": location['altitude'],
|
||||
"horizontalAccuracy": location['horizontalAccuracy'],
|
||||
"verticalAccuracy": location['verticalAccuracy'],
|
||||
"batteryLevel": status['batteryLevel'],
|
||||
"deviceDisplayName": status['deviceDisplayName'],
|
||||
"deviceStatus": status['deviceStatus'],
|
||||
"name": status['name']
|
||||
"latitude": location["latitude"],
|
||||
"longitude": location["longitude"],
|
||||
"timeStamp": location["timeStamp"],
|
||||
"altitude": location["altitude"],
|
||||
"horizontalAccuracy": location["horizontalAccuracy"],
|
||||
"verticalAccuracy": location["verticalAccuracy"],
|
||||
"batteryLevel": status["batteryLevel"],
|
||||
"deviceDisplayName": status["deviceDisplayName"],
|
||||
"deviceStatus": status["deviceStatus"],
|
||||
"name": status["name"],
|
||||
}
|
||||
response = ICloudReturnData(**data)
|
||||
return response
|
||||
logger.info("Location payload is None for device=%s", self.selected_device.name)
|
||||
logger.info(
|
||||
"Location payload is None for device=%s", self.selected_device.name
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
@@ -287,8 +313,15 @@ class FindMyMonitor:
|
||||
|
||||
async def refresh_location(self):
|
||||
"""Fetch one location update while serializing iCloud API access."""
|
||||
logger.info(
|
||||
"Refreshig fmf_location for device id='%s' name='%s'",
|
||||
self.selected_device_id,
|
||||
self.selected_device_name,
|
||||
)
|
||||
async with self._fetch_lock:
|
||||
return await self.get_location()
|
||||
fmf_info = await self.get_location()
|
||||
logger.info("fmf_location: '%s'", fmf_info)
|
||||
return fmf_info
|
||||
|
||||
async def run_monitor(self, interval=60):
|
||||
"""Runs the monitor loop."""
|
||||
|
||||
@@ -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 typing import Optional
|
||||
from typing import Optional, TypedDict, NotRequired
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Favorite(TypedDict):
|
||||
id: NotRequired[int]
|
||||
name: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
radius: float
|
||||
icon: str
|
||||
address: dict[str, str | None]
|
||||
|
||||
class Category(TypedDict):
|
||||
category_id: NotRequired[int]
|
||||
name: str
|
||||
icon: str
|
||||
color: str
|
||||
|
||||
|
||||
class Coordinate(BaseModel):
|
||||
latitude: float
|
||||
|
||||
+115
-2416
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 = "typer" },
|
||||
{ name = "typing" },
|
||||
{ name = "uptime" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
@@ -1376,6 +1377,7 @@ requires-dist = [
|
||||
{ name = "sqlalchemy-orm", specifier = ">=1.2.10" },
|
||||
{ name = "typer", specifier = ">=0.24.1" },
|
||||
{ name = "typing", specifier = "==3.10.0.0" },
|
||||
{ name = "uptime", specifier = ">=3.0.1" },
|
||||
{ name = "uvicorn", specifier = "==0.41.0" },
|
||||
]
|
||||
|
||||
@@ -1893,6 +1895,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uptime"
|
||||
version = "3.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/53/6c420ddf6949097d6f9406358951c9322505849bea9cb79efe3acc0bb55d/uptime-3.0.1.tar.gz", hash = "sha256:7c300254775b807ce46e3dcbcda30aa3b9a204b9c57a7ac1e79ee6dbe3942973", size = 6630, upload-time = "2013-10-07T14:19:58.456Z" }
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
|
||||
Reference in New Issue
Block a user