milestones

This commit is contained in:
2026-06-10 11:06:33 -04:00
parent 64ee9797ff
commit 9dac5fe744
18 changed files with 3275 additions and 2523 deletions
View File
+4
View File
@@ -1,3 +1,7 @@
__pycache__/* __pycache__/*
*.pyc *.pyc
cookies/* cookies/*
/cookies/*
geocache.db
/.uv-cache/
/AI Chats/
+34
View File
@@ -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>
+3
View File
@@ -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
View File
Binary file not shown.
+1
View File
@@ -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",
] ]
+232 -66
View File
@@ -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,89 +37,167 @@ 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()
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.commit() conn.commit()
return cursor.rowcount > 0
else:
return False
async def set_favorite(self, lat: float, lon: float, favorite_data: dict) -> bool: def _store_favorite(self, key: str, name: str, category_id: int, favorite_data: Favorite) -> None:
key = self._cache_key(lat, lon)
name = favorite_data.get("name")
if not name:
return False
logger.info("Setting favorite location %s to location_cache for %s", name, key)
try:
with sqlite3.connect(self.db_path) as conn: 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() conn.commit()
return True if favorite_id is not None:
except sqlite3.Error as e: self._set_favorite(key, favorite_id)
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_category(self, name: str, icon: str, color: str) -> None:
"""Reverse geocode with SQLite cache.""" with sqlite3.connect(self.db_path) as conn:
logger.info("Checking location_cache for favorites") conn.execute("PRAGMA foreign_keys = ON")
favorites = self._read_favorites() conn.execute(CATEGORY_UPSERT_SQL, (name, icon, color))
return favorites conn.commit()
async def get_address(self, lat: float, lon: float) -> dict | 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:
key = self._cache_key(lat, lon) conn.execute(FAVORITE_CACHE_ALL_INSERT_SQL, (key, json.dumps(address_data), favorite_id))
cached_address = self._read_cached_address(key) conn.commit()
if cached_address is not None:
logger.info("Found cached address for %s", key)
return cached_address
def _clear_favorite(self, favorite_id: int) -> None:
with sqlite3.connect(self.db_path) as conn:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(FAVORITE_CLEAR_SQL, (favorite_id,))
conn.commit()
def _delete_category(self, category_id: int) -> None:
with sqlite3.connect(self.db_path) as conn:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(CATEGORY_CLEAR_SQL, (category_id,))
async def _lookup_address(self, key: str) -> dict | None:
async with Nominatim( async with Nominatim(
user_agent=self.user_agent, user_agent=self.user_agent,
adapter_factory=AioHTTPAdapter adapter_factory=AioHTTPAdapter
@@ -127,12 +207,98 @@ class AsyncReverseGeocoder:
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
+256
View File
@@ -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()
+58 -25
View File
@@ -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."""
@@ -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()
)
+16 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+782
View File
@@ -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}",
}
+85
View File
@@ -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)
Generated
+8
View File
@@ -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"