extensive changes

This commit is contained in:
2026-03-27 17:12:20 -04:00
parent c5a563f047
commit 1eef99e3b4
29 changed files with 3535 additions and 2499 deletions

10
.idea/back-end.iml generated
View File

@@ -1,5 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<module external.system.id="java-source" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="jdk" jdkName="uv (pymd3_vue_location_sim)" jdkType="Python SDK" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/back-end.iml" filepath="$PROJECT_DIR$/.idea/back-end.iml" />
</modules>
</component>
</project>

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

0
README.md Normal file
View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,24 @@
#LWP-Cookies-2.0
Set-Cookie3: dslang=US-EN; path="/"; domain=.apple.com; path_spec; secure; discard; HttpOnly=None; version=0
Set-Cookie3: site=USA; path="/"; domain=.apple.com; path_spec; secure; discard; HttpOnly=None; version=0
Set-Cookie3: acn01="vWB+8XG/BRIWimhzMz5URIMyy8lepYRnuuCxqAANNsVFbcxf"; path="/"; domain=.apple.com; path_spec; secure; expires="2027-03-27 09:37:26Z"; HttpOnly=None; version=0
Set-Cookie3: aasp=90537484E4B81989243BAABCFA1DE44F53876FB5F9C433FD2C4687BD0D409642748545163AD333F3CDBBDDC6128BF84382082E6C0A4E27921C8792F0883DABFBFE8534D699F84266A14F842D6502C6902F2F00DC126EBD3E7D5FF7C490D9FE6740C97EFA884B52031C2E5BB2469A785CBDD236AD7014E383; path="/"; domain=.idmsa.apple.com; path_spec; secure; discard; HttpOnly=None; version=0
Set-Cookie3: DES580750186337023c50d1415a6e6ca44a2="HSARMTKNSRVXWFlajR2ecD1662phQjqU9vXxnL49ZjypuVYYXHDpA3wTiX6Mf2J4WDlIhZj52z81aDOuz+VC80bVhV41TSNN4ggoPjW8WnsQrjniTQYkgJycPQNnzhkK4hfe2AMrr/bhrJJm8sHHc+Oh1HUckN6T7T4c1bmf2Qg9tRwsdRDNyMMyFH/Ml/cQlWKj39/YHlY=SRVX"; path="/"; domain=.idmsa.apple.com; path_spec; secure; expires="2026-04-21 03:09:12Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-UNIQUE-CLIENT-ID="\"BA==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; discard; version=0
Set-Cookie3: X-APPLE-WEBAUTH-LOGIN="\"v=1:t=BA==BST_IAAAAAAABLwIAAAAAGnGXmMRDmdzLmljbG91ZC5hdXRovQCH_epzZatm6b76myo9LfRsbXIfkwxREvo7U3efqnjYgORbMDpBEJyshI1bhC-Ww30ipqYT87rp7mJxkcXRw4HYHvxJCs1rC9M6JWbFvrmUTu4RuVuaVYSNHRYAz8-DS6bagfD6XUHY-5Xmqf2fnmeGqZfNag~~\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; discard; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-VALIDATE="\"v=1:t=BA==BST_IAAAAAAABLwIAAAAAGnGXmMRDmdzLmljbG91ZC5hdXRovQCH_epzZatm6b76myo9LfRsbXIfkwxREvo7U3efqnjYgORbMDpBEJyshI1bhC-Ww30ipqYT87rp7mJxkcXRw4HYHvxJCs1rC9M6JWbFvrmUTllbuUPSPqBVc1raHL34Cl5lu7SfciATbqjQE4KgZchJLIo6PQ~~\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; discard; version=0
Set-Cookie3: X-APPLE-WEBAUTH-USER="\"v=1:s=1:d=157320350\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X_APPLE_WEB_KB-FHMLYL_TPMN_3A8D3KIPPI0C_EC="\"v=1:t=BA==BST_IAAAAAAABLwIAAAAAGm_OLQRDmdzLmljbG91ZC5hdXRovQD38nYoxQenHW9WggeFKkoDa8I8zeKoOshv6I4dsZQalR2itry1r6kUZe9d_BZan1W-oKlImTrYi_-Vt5Q4YEJWJITWeqN8QChxvbTXB0o8sQ-wAIzBL1J5sQIRBqMadrtP5U0wslkRg0u0AguK20CM4TGoGg~~\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-05-21 00:32:52Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-DS-WEB-SESSION-TOKEN="\"AQHO22bhY5UiUtuYBNJRupGy4tIGYR08ChFW77qgTTPDpPof4ZOPHhm2rGQ7OnoHchQlJLXG77T2BEoZxUO7ZknuRUUoK/j9/XTHYwv92CgFs/STC7oB1aMYmfddwNigbg9L5v40kX3Fmg6teX34bGYuOfqoHKTCuVxhllBNxZsKcPsKLGqRdZ4b3thHu+Y6+uVq5mAT9RnUbYAMD7hYP1Vr4aq+6CYi4+TdtO02SngpGcCRC5BR3PJh2udSG2YSGk5T+SBb4uBx94CzF2116QQWR0WjwSpqJ2K881lENtE3Kvf0pmM5mIw9fLdSgDi6kqP8iJHdni85QYpuTasMzd7ROWih6OqSV0U3O62X8ix+Xf3P1e/Fes7prNxjME+tcORKftTockp3c5U28DKcnZYYYuBRh+BNaAqVIAMg7AW9dNJUuTWdzVtUPhkLgvyTal625iiSh7Q6wsIISa03Cjtueli3NV++Z3R2jFvTQ/mC1lH9U//3ul+UPqblKCBy7SKGi/oph/PIroggTUwx/tvjQxCQf8RVkGg+8T0UE7rj6wDuaYu1i7twt7o1QsBlefEpVU9fYhCK3sq5aydhLRsb47wX42WhftBRTDenq7SwaHJZfxQ3T4NVULg8OsuCeuO1Gejnbylh9BI3Ws+I5k0ZNOaZtAWBlW8dsa5KrEdr+cRG0oV4EKPGTbt/mhvdFA/Kes/ksfI27DkKDmQ0MVj7nTTXQSkxeeQWEuP3T5LSNaYGDl+E6zqn5SsTt1RGxYMj3SHfSzxcLjNk7RoGcNle45L+V1wA5G8=\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-HSA-TRUST="\"28a33818a1dce9a0eecde38e7c8fcc6f080b70bc9feb505599fb2855903a4792_HSARMTKNSRVXWFlajR2ecD1662phQjqU9vXxnL49ZjypuVYYXHDpA3wTiX6Mf2J4WDlIhZj52z81aDOuz+VC80bVhV41TSNN4ggoPjW8WnsQrjniTQYkgJycPQNnzhkK4hfe2AMrr/bhrJJm8sHHc+Oh1HUckN6T7T4c1bmf2Qg9tRwsdRDNyMMyFH/Ml/cQlWKj39/YHlY=SRVX\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-06-25 10:39:31Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Events="\"S2V5QXBwbDoBAAAA8QQnAADHJQyTmjkr1lRgHCHPONwUkefefsNrEGp6R9lQhFZ2hQ==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Documents="\"S2V5QXBwbDoBAAAAAgQnAAATRltQsTNPTggWr2+h+Ck7taXy1Nfd/0gTtx4/mgZMuQ==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Photos="\"S2V5QXBwbDoBAAAAAwQnAAC2DQKpt8kGxmIXjouUofkM/o9x9ZcMbMi8+kob3iqjtg==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Cloudkit="\"S2V5QXBwbDoBAAAABAQnAAD32tR1O4Wfr112mDd798bvrPSnGJ9SGs1UAu8ZxpArag==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Safari="\"S2V5QXBwbDoBAAAAFgQnAAAPeaT9obfpF4JkzvnTqn9iKRIFWjCO4Ibdd3ETw14+Kw==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Mail="\"S2V5QXBwbDoBAAAABwQnAADn5vf2KKnwETMPFGonUQAJwl4zr2g3X+iUWawSMgWOEg==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Notes="\"S2V5QXBwbDoBAAAACQQnAAALAg7ubdhjkrtB/lFNTwHudI77mxFx9gsBauT9LBhB6g==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-News="\"S2V5QXBwbDoBAAAACwQnAAA5X6JspeIixJp6c7Uy+R2YynkGzH97NPfDPAkrO7uZ8g==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-PCS-Sharing="\"S2V5QXBwbDoBAAAADAQnAADz1ui+JqfV6Fy6AI0Z6DScPVidn3wzooDronhTKnsLmQ==\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-26 10:39:35Z"; HttpOnly=None; version=0
Set-Cookie3: X-APPLE-WEBAUTH-TOKEN="\"v=2:t=BA==BST_IAAAAAAABLwIAAAAAGnGcucRDmdzLmljbG91ZC5hdXRovQBTsTtaO6tyhJbzfEVId4DPvNxvrOsTyKuhGBini_Ov9HBK9EgpLZCsfHxc0R0TWp-8AWGCWsLJSRkerU_Wt4CQG-vY_elLY1vevhKMmIi2CdVVyTBenEo_PyeAkNds0z9SjSi5a5z5rBqJpSysM1UyXNTTcg~~\""; path="/"; domain=.icloud.com; path_spec; domain_dot; secure; expires="2026-04-10 12:07:03Z"; HttpOnly=None; version=0
Set-Cookie3: xr_3n2093n1a="BW/k2XvBcxLsX55iCOOGgMojIY0Q+/Ey61qJDHU0TQE="; path="/"; domain=p144-fmipweb.icloud.com; path_spec; secure; discard; HttpOnly=None; version=0

View File

@@ -0,0 +1 @@
{"client_id": "a803c3ce-2586-11f1-a724-8f6777a1d2b5", "session_id": "90537484E4B81989243BAABCFA1DE44F53876FB5F9C433FD2C4687BD0D409642748545163AD333F3CDBBDDC6128BF84382082E6C0A4E27921C8792F0883DABFBFE8534D699F84266A14F842D6502C6902F2F00DC126EBD3E7D5FF7C490D9FE6740C97EFA884B52031C2E5BB2469A785CBDD236AD7014E383", "auth_attributes": "CpWqHR6Y8JEnXubiHUHm2K5yCpOQORPPkclAkiMbW2nsEkSK2oVF24FHI3QNNw4cfBQfJOSEdRvVtGKtbi/Zxzbma2uaF9uD1kAlxlin4oPpxzNH5xKC472p08YUBkvusVaZ4wPqv6Zv+MZefISD/f/5A3vIn2fTZbXgImoKKaTLlwSn5wOcaJYoJYAhWgak+YPWeGLAdm1oGuXvTkCZe3gYtizo8lu6d4SBEjQ18GJ0BAN8D+BRxOb9B1AhxnzUd25aKq4CXzkRmLLCQWX0AA02xVKHQTE=", "scnt": "AAAA-jkwNTM3NDg0RTRCODE5ODkyNDNCQUFCQ0ZBMURFNDRGNTM4NzZGQjVGOUM0MzNGRDJDNDY4N0JEMEQ0MDk2NDI3NDg1NDUxNjNBRDMzM0YzQ0RCQkREQzYxMjhCRjg0MzgyMDgyRTZDMEE0RTI3OTIxQzg3OTJGMDg4M0RBQkZCRkU4NTM0RDY5OUY4NDI2NkExNEY4NDJENjUwMkM2OTAyRjJGMDBEQzEyNkVCRDNFN0Q1RkY3QzQ5MEQ5RkU2NzQwQzk3RUZBODg0QjUyMDMxQzJFNUJCMjQ2OUE3ODVDQkREMjM2QUQ3MDE0RTM4M3wyAAABnS61l867bZUy_24a4kaFbwGNgVJFgWQLrkDm8d_dAuxiqTUQBdnQ9m4C0ia8AA02xTey6hfMfAIuPPlov20tqtmvTh-N23rQ3Q4jQeaNx6uMrtovmQ", "account_country": "USA", "session_token": "idWfvF23GAFDtpDv51Mnga3iu7fJCAdyDaw22SorSHz5JTRc/SottqqYD2KGG/56FLnY9lHAZnJUz8APx24/Y5Udfi45jsW6FmUZ1btGt4ihu0lGg5zIWAxY6B9AHVe4ROZ0mI90mvT5jajGL56Ke3fhL6ncIYP/LTQz2EJfwZtd02MgeQ5j1W0IfF8zmhlDyb9eAn1dC6X61hh7d/DZdLDFfZxxyXkBhemi6dNQKDwAUnYl7YKlShZDLUFNFG0k3m2yiLQANI+InL7fDHPEdyiNaGh9Lu5VLcq7hgNFqIqN6JFjUBAr/cauSeTHHmGPtH9Ry4VWMpl3Ukd+nppFylMCazMj3hvjXpSIkMR7B41pxxRA1FtjgYryeUkexcQXx8vrMYwrkVdtOB4p2xRfX7xXzMGH5VpwpFO75/nO10A4n+YEXg/JaPW31R8zLBvnZd6oL5ElCGIlXQadsPM0mzDdbvM2dXhMQaJ4dLQC9p4XhIrEkGhV7op5YeLWfNiDg3tBDLYn+WVEw7xWmEp6/NRZqFmctEDgJak9AwUV5ostJ4O69UxiaqkL8VgYysHBVcN0Tl6XwVHvh+o5LBL3C9fAsQ1ZIMNb088Sl8iRHbwOxa/UfiQ9RWiTfUhlGzNhd6nxOLIN5SaBV4Z3Xu4asRpP00Ue0igWBzKmn6nTk5lEMABMA+fiDVYdpuN0dNWbscZwLLozBsToayEgfDD/KdYCdqEV8y1mL97z7nSjB1VAfs5x3Skhq6AhKIe9p1Y9AbsOJP8vs+p+G+xHi/sKKzz8r3g27yNpP7emIO6lioYeuHTg341QSGJtPofrq9d+04J08dgnvxJ6dsfyGrYYGjIV9OYidbhMlXkXPofkMVA+1SCjVi1pQOPtlZ8ygzJK4+OiLK8NJJR5tcaMNSZ0l16RIoZw+IdEi2invyN/SXQqjVyie10sEbRarN8cVOJ58AANNsVYFrWa", "trust_eligible": "true", "grant_code": "cca13786ef08a405f94e575e9e8501ab7.0.mrzwz.y2Ef8JBOyp06v2uZhFL20g", "trust_token": "HSARMTKNSRVXWFlajR2ecD1662phQjqU9vXxnL49ZjypuVYYXHDpA3wTiX6Mf2J4WDlIhZj52z81aDOuz+VC80bVhV41TSNN4ggoPjW8WnsQrjniTQYkgJycPQNnzhkK4hfe2AMrr/bhrJJm8sHHc+Oh1HUckN6T7T4c1bmf2Qg9tRwsdRDNyMMyFH/Ml/cQlWKj39/YHlY=SRVX"}

View File

@@ -1,27 +0,0 @@
from sqlalchemy import create_engine
from dqlalchemy.orm import sessionmaker, declaritive_base
DATABASE_URL = "sqlite:///./locations.db"
engine = create_engine(
DATABASE_URL, connect_engine("check_same_thread": false}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for getting database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_all_tables():
"""Creates all tables defined with Base in the database."""
# Note: import models before calling create_all_tables()
Base.metadata_create_all(bind=engine)
print("Database and tables created.")

View File

@@ -1,58 +0,0 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy.orm import relationship
from .database import Base
class Location(Base):
"""
SQLAlchemy model for the 'locations' table.
"""
__tablename__ = "locations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=False, index=True)
address = Column(String, unique=False, index=True)
latitude = Column(Float, unique=False, index=True)
longitude = Column(Float, unique=False, index=True)
is_favorite = Column(Boolean, unique=False, index=True)
def __repr__(self):
return f"<Location(name='{self.name}', address='{self.address}', latitude='{self.latitude}', longitude='{self.longitude}', is_favorite='{self.is_favorite}')>"
class Route(Base):
"""
SQLAlchemy model for the 'routes' table.
"""
__tablename__ = "routes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
# Start and Endpoints (One-to-Many relationship)
start_location_id = Column(Integer, ForeignKey('locations.id'))
end_location_id = Column(Integer, ForeignKey('locations.id'))
start_location = relationship("Location", foreign_keys=[start_id])
end_location = relationship("Location", foreign_keys=[end_id])
# Relationship to get waypoints ordered
waypoints = relationship("Waypoint", order_by="Waypoint.order", back_populates="route")
def __repr__(self):
return f"<Route(name='{self.name}', start='{self.start_location.name}', end='{self.end_location.name}')>"
# Association Table for Many-to-Many relationsjip (Routes <-> Waypoints)
class Waypoint(Base):
"""
SQLAlchemy model for the 'waypoints' table.
"""
__tablename__ = 'waypoints'
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey('routes.id'))
location_id = Column(Integer, ForeignKey('locations.id'))
order = Column(Integer, nullable=False)
route = relationship("Route", back_populates="waypoints")
location = relationship("Location")

111
icloud.py
View File

@@ -1,111 +0,0 @@
import asyncio
from dotenv import load_dotenv
import os
import json
import logging
from pyicloud import PyiCloudService
from pyicloud.exceptions import PyiCloud2FARequiredException
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=True)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root_logger = logging.getLogger()
root_logger.handlers = [handler]
root_logger.setLevel(logging.INFO)
logger = logging.getLogger("ios-api")
class FindMyMonitor:
def __init__(self, queue: asyncio.Queue, token_file="icloud_token.txt"):
self.username = os.getenv("APPLE_ID")
self.password = os.getenv("APPLE_PW")
self.token_file = token_file
self.queue = queue
self.api = None
self.device = None
self.running = True
async def authenticate(self):
"""Authenticates with iCloud, handling 2FA and token storage."""
if os.path.exists(self.token_file):
print("Loading stored session...")
self.api = PyiCloudService(self.username, cookie_directory="./cookies")
else:
print("No stored session. Authenticating...")
self.api = PyiCloudService(self.username, self.password, cookie_directory="./cookies")
if self.api.requires_2fa:
print("Two-factor authentication required.")
code = input("Enter the code you received: ")
result = self.api.validate_2fa_code(code)
print(f"Code validation result: {result}")
if not result:
print("Failed to verify 2FA code")
return False
# Trust the session
self.api.trust_session()
print("Successfully authenticated.")
return True
async def get_location(self):
"""Fetches the latest latitude and longitude."""
if not self.api:
await self.authenticate()
# Refresh API data
self.api.refresh_client()
# Find the device (modify name to match your iPhone name in iCloud)
if not self.device:
# Assuming you have devices, pick the first or match by name
self.device = self.api.devices[0]
print(f"Monitoring device: {self.device.name()}")
location = self.device.location()
if location:
return location['latitude'], location['longitude'], location['timeStamp']
return None
def start(self):
self.running = True
def stop(self):
self.running = False
async def run_monitor(self, interval=60):
"""Runs the monitor loop."""
if not await self.authenticate():
return
if not self.running:
self.start()
while self.running:
logger.info("Starting iCloud FMF loop")
try:
lat, lon, ts = await self.get_location()
print(f"[{ts}] Location: {lat}, {lon}")
# Add your logic to update database/API here
await self.queue.put(lat, lng, ts)
except Exception as e:
print(f"Error: {e}")
# Re-authenticate if session expired
await self.authenticate()
await asyncio.sleep(interval)

50
main.bk
View File

@@ -1,50 +0,0 @@
from flask import Flask, request, jsonify
from flask_cors import CORS
app = Flask(__name__, static_folder="../front-end/dist/assets", template_folder="../front-end/dist")
CORS(app)
@app.route('/')
def index():
return app.send_static_file('index.html')
# Essential for handling client-side routing (e.g., Vue Router history mode)
@app.route('/<path:path>')
def static_files(path):
return app.send_static_file(path)
@app.route('/api/set', methods=['POST'])
def handle_json_data():
data = request.get_json()
if not data:
return jsonify({'error': 'Missing JSON data'}), 400
if 'lat' not in data:
return jsonify({'error': 'Missing lat in JSON data'}), 400
if 'lng' not in data:
return jsonify({'error': 'Missing lng in JSON data'}), 400
# Process the data (e.g., save to a database)
lat = data['lat']
lng = data['lng']
# Return a JSON response
return jsonify({
'message': f'lat: {lat}, lng: {lng} received successfully!',
'data': data
}), 200
@app.route('/api/status', methods=['GET'])
def get_data():
return jsonify(message="Hello from Flask API!")
@app.errorhandler(404)
def not_found(error):
return jsonify({'error': 'Not found'}), 404
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)

44
main.py
View File

@@ -1,48 +1,16 @@
import asyncio
import dataclasses
import json
import logging
import random
import sys
import socketio
import tempfile
from contextlib import nullcontext
from functools import partial
from pathlib import Path
from typing import Annotated, Optional, TextIO
from typing import Annotated
import typer
from typer_injector import InjectingTyper
from pymobiledevice3.bonjour import DEFAULT_BONJOUR_TIMEOUT, browse_remotepairing_manual_pairing
from pymobiledevice3.cli.cli_common import (
RSDServiceProviderDep,
async_command,
print_json,
prompt_device_list,
sudo_required,
user_requested_colored_output,
)
from pymobiledevice3.common import get_home_folder
from pymobiledevice3.exceptions import NoDeviceConnectedError
from pymobiledevice3.pair_records import PAIRING_RECORD_EXT, get_remote_pairing_record_filename
from pymobiledevice3.remote.common import ConnectionType, TunnelProtocol
from pymobiledevice3.remote.module_imports import MAX_IDLE_TIMEOUT, start_tunnel, verify_tunnel_imports
from pymobiledevice3.remote.remote_service_discovery import RSD_PORT
from pymobiledevice3.remote.tunnel_service import (
RemotePairingManualPairingService,
get_core_device_tunnel_services,
get_remote_pairing_tunnel_services,
)
from pymobiledevice3.remote.utils import get_rsds
from pymobiledevice3.remote.common import TunnelProtocol
from pymobiledevice3.remote.module_imports import verify_tunnel_imports
from pymobiledevice3.tunneld.api import TUNNELD_DEFAULT_ADDRESS
from pymobiledevice3.utils import run_in_loop
from server import TunneldRunnerSio, LocationSimulationState, logger
from src.pymd3_vue_location_sim.server import TunneldRunnerSio, LocationSimulationState
from src.pymd3_vue_location_sim.json_formatter import logger
def main():
cli_tunneld(host="0.0.0.0", port=8000)
cli_tunneld(host="0.0.0.0", port=49151)
def cli_tunneld(
host: Annotated[str, typer.Option(help="Address to bind the tunneld server to.")] = TUNNELD_DEFAULT_ADDRESS[0],

View File

@@ -3,12 +3,18 @@ name = "back-end"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"click>=8.3.1",
"daemonize>=2.5.0",
"fastapi==0.135.1",
"geopy==2.4.1",
"pydantic==2.12.5",
"pyicloud>=2.4.1",
"pymobiledevice3==9.0.0",
"python-dotenv>=1.2.2",
"python-socketio==5.16.1",
"sqlalchemy>=2.0.48",
"sqlalchemy-orm>=1.2.10",
"typer>=0.24.1",
"typing==3.10.0.0",
"uvicorn==0.41.0",
]

1102
server.py

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
import uvicorn
from fastapi import FastAPI
import socketio
app = FastAPI()
sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="asgi")
socket_app = socketio.ASGIApp(sio, app)
@app.get("/")
def main():
return {"message": "Hello World"}
@sio.on("connect")
async def connect(sid, env):
print("New Client Connected to This id :" + " " + str(sid))
await sio.emit("message", f"Hello Client, Welcome to Socket.IO Server {sid}")
@sio.event
async def message(sid, data):
print("Message from Client: " + str(data))
return True
@sio.on("disconnect")
async def disconnect(sid):
print("Client Disconnected: " + " " + str(sid))
@sio.on('*')
async def any_event(event, sid, data):
print("Unregisered event received, event: " + str(event) + " sid: " + str(sid) + " data: " + str(data))
if __name__ == "__main__":
uvicorn.run(
"socktest:socket_app", host="0.0.0.0", port=8000, lifespan="on", reload=True
)

0
src/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,44 @@
from typing import List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from .db_models import Base, Location, Route, Waypoint
DATABASE_URL = "sqlite+aiosqlite:///.locations.db"
engine = create_async_engine(DATABASE_URL, echo=True)
async_session_local = async_sessionmaker(engine, expire_on_commit=False)
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def create_location(name: str, address: str, latitude: float, longitude: float, is_favorite: bool = False):
async with async_session_local() as session:
new_location = Location(name=name, address=address, latitude=latitude, longitude=longitude, is_favorite=is_favorite)
session.add(new_location)
await session.commit()
return new_location
async def get_locations():
async with async_session_local() as session:
result = await session.execute(select(Location))
return result.scalars().all()
async def create_route(name: str, origin_id: int, destination_id: int, waypoints_data: List[dict]):
async with async_session_local() as session:
new_route = Route(name=name, origin_id=origin_id, destination_id=destination_id)
for wp in waypoints_data:
new_route.waypoints.append(Waypoint(order=wp['order'], description=wp['description']))
session.add(new_route)
await session.commit()
return new_route
async def get_routes():
async with async_session() as session:
# Use joinedload for efficient loading of relationships
from sqlalchemy.orm import joinedload
result = await session.execute(
select(Route).options(joinedload(Route.waypoints))
)
return result.scalars().unique().all()

View File

@@ -0,0 +1,42 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.ext.asyncio import AsyncAttrs
from typing import List
class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
address: Mapped[str] = mapped_column(String(255))
latitude: Mapped[float]
longitude: Mapped[float]
is_favorite: Mapped[bool] = mapped_column(default=False)
routes: Mapped[List["Route"]] = relationship(back_populates="destination", cascade="all, delete-orphan")
class Route(Base):
__tablename__ = "routes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
origin_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
destination_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
destination: Mapped["Location"] = relationship(back_populates="routes")
waypoints: Mapped[List["Waypoint"]] = relationship(back_populates="route", cascade="all, delete-orphan")
class Waypoint(Base):
__tablename__ = "waypoint"
id: Mapped[int] = mapped_column(primary_key=True)
order: Mapped[int]
description: Mapped[str]
route_id: Mapped[int] = mapped_column(ForeignKey("route.id"))
route: Mapped["Route"] = relationship(back_populates="waypoints")

View File

@@ -0,0 +1,334 @@
import asyncio
import logging
import os
import sys
from collections.abc import Callable
import click
from dotenv import load_dotenv
from pyicloud import PyiCloudService
from pyicloud.exceptions import (
PyiCloud2FARequiredException,
PyiCloud2SARequiredException,
PyiCloudAuthRequiredException,
PyiCloudFailedLoginException,
PyiCloudNoStoredPasswordAvailableException,
PyiCloudPasswordException,
)
from .models import iCloudReturnData
load_dotenv()
logger = logging.getLogger("ios-api")
AUTH_EXCEPTIONS = (
PyiCloudAuthRequiredException,
PyiCloudFailedLoginException,
PyiCloudPasswordException,
PyiCloud2FARequiredException,
PyiCloud2SARequiredException,
PyiCloudNoStoredPasswordAvailableException,
)
class FindMyMonitor:
def __init__(
self,
queue: asyncio.Queue,
token_file: str = "icloud_token.txt",
sio=None,
get_client_sids: Callable[[], list[str]] | None = None,
code_timeout_seconds: int = 180,
):
self.username = os.getenv("APPLE_ID")
self.password = os.getenv("APPLE_PW")
self.token_file = token_file
self.selected_device_id = os.getenv("SELECTED_DEVICE_ID")
self.selected_device_name = os.getenv("SELECTED_DEVICE_NAME")
self.selected_device = None
self.queue = queue
self.api = None
self.device = None
self.running = True
self.sio = sio
self.get_client_sids = get_client_sids
self.code_timeout_seconds = code_timeout_seconds
self.auth_init_timeout_seconds = int(
os.getenv("ICLOUD_AUTH_INIT_TIMEOUT_SECONDS", "0")
)
self._logged_candidates = False
self._no_location_streak = 0
self._auth_error_streak = 0
async def _request_code_from_vue(self, prompt: str) -> str | None:
if self.sio is None or self.get_client_sids is None:
logger.warning("2FA request skipped: Socket.IO context not configured")
return None
sids = self.get_client_sids()
if not sids:
logger.warning("2FA request skipped: no connected Socket.IO clients")
return None
payload = {"prompt": prompt, "digits": 6}
logger.info("Emitting icloud_2fa_request to %s connected client(s)", len(sids))
for sid in sids:
try:
# Ask one connected UI client for the code and wait for ACK response.
response = await self.sio.call(
"icloud_2fa_request",
payload,
to=sid,
namespace="/",
timeout=self.code_timeout_seconds,
)
except Exception:
logger.exception("Failed to retrieve 2FA code from sid=%s", sid)
continue
code = response.get("code") if isinstance(response, dict) else response
code_str = str(code).strip() if code is not None else ""
if len(code_str) == 6 and code_str.isdigit():
return code_str
logger.warning("Invalid 2FA code payload from sid=%s", sid)
return None
async def authenticate(self):
"""Authenticates with iCloud, handling 2FA and token storage."""
if not self.username:
logger.warning("APPLE_ID is not configured; skipping iCloud monitor authentication")
return False
has_token = os.path.exists(self.token_file)
if not has_token and not self.password:
logger.warning(
"No stored iCloud session and APPLE_PW is not configured; skipping iCloud monitor authentication"
)
return False
if os.path.exists(self.token_file):
print("Loading stored session...")
try:
init_task = asyncio.to_thread(
PyiCloudService, self.username, cookie_directory="./cookies"
)
if self.auth_init_timeout_seconds > 0:
self.api = await asyncio.wait_for(
init_task, timeout=self.auth_init_timeout_seconds
)
else:
self.api = await init_task
except Exception as e:
logger.exception("Failed to initialize iCloud session from cookies: %s", e)
return False
else:
print("No stored session. Authenticating...")
try:
init_task = asyncio.to_thread(
PyiCloudService,
self.username,
self.password,
cookie_directory="./cookies",
)
if self.auth_init_timeout_seconds > 0:
self.api = await asyncio.wait_for(
init_task, timeout=self.auth_init_timeout_seconds
)
else:
self.api = await init_task
except Exception as e:
logger.exception("Failed to initialize iCloud session with credentials: %s", e)
return False
if self.api.requires_2fa:
print("Two-factor authentication required.")
code = await self._request_code_from_vue("Enter the 6-digit Apple verification code")
if code is None:
if sys.stdin and sys.stdin.isatty():
code = str(
await asyncio.to_thread(
click.prompt,
"Please enter the 6-digit code sent to your trusted device",
type=int,
)
)
else:
logger.warning(
"2FA required but no interactive terminal or Vue responder is available; deferring authentication"
)
return False
# Verify the code
result = await asyncio.to_thread(self.api.validate_2fa_code, code)
print(f"2FA validation result: {result}")
if not result:
print("Failed to verify 2FA code")
return False
# Trust the session
await asyncio.to_thread(self.api.trust_session)
if self.api.requires_2sa:
import textwrap
print(textwrap.dedent("""
Two-step authentication required.
Please select a device to receive a SMS verification code:
"""))
# List available devices for 2SA
for i, device in enumerate(self.api.trusted_devices):
print(
f" {i + 1}: {device.get('deviceName', 'Unknown device')} ({device.get('phoneNumber', 'Unknown number')})")
# Prompt the user for their choice
if not (sys.stdin and sys.stdin.isatty()):
logger.warning(
"2SA required but no interactive terminal is available; deferring authentication"
)
return False
device_index = await asyncio.to_thread(click.prompt, "Please select a device number", type=int) - 1
device = self.api.trusted_devices[device_index]
if not await asyncio.to_thread(self.api.send_verification_code, device):
print("Failed to send verification code")
return False
# Prompt the user to enter the verification code they received
code = await asyncio.to_thread(click.prompt, "Please enter verification code", type=int)
if not await asyncio.to_thread(self.api.validate_verification_code, device, code):
print("Failed to verify verification code")
return False
print("Successfully authenticated.")
return True
async def get_location(self):
"""Fetches the latest latitude and longitude."""
if not self.api:
await self.authenticate()
# pyicloud 2.4.1 refresh lives on the FindMy manager, not PyiCloudService.
devices_manager = await asyncio.to_thread(lambda: self.api.devices)
await asyncio.to_thread(devices_manager.refresh, True)
# One-time diagnostics to help verify exact device selection among duplicates.
if not self._logged_candidates:
for d in devices_manager:
status = d.status()
logger.debug(
"iCloud candidate id=%s name=%s model=%s deviceStatus=%s has_location=%s features=%s",
d.data.get("id"),
d.name,
d.model_name,
status.get("deviceStatus"),
d.data.get("location") is not None,
d.data.get("features"),
)
self._logged_candidates = True
# Select by ID first (exact), then by name, then first device if no selectors set.
self.selected_device = None
for device in devices_manager:
if self.selected_device_id and device.data.get("id") == self.selected_device_id:
self.selected_device = device
break
if self.selected_device is None:
for device in devices_manager:
if self.selected_device_name and device.name == self.selected_device_name:
self.selected_device = device
break
if self.selected_device is None and not self.selected_device_id and not self.selected_device_name:
for device in devices_manager:
self.selected_device = device
break
if self.selected_device:
location = self.selected_device.location
status = self.selected_device.status()
logger.info(
"iCloud device=%s location_available=%s status=%s",
self.selected_device.name,
self.selected_device.location_available,
status,
)
if location:
data = {
"latitude": location['latitude'],
"longitude": location['longitude'],
"timeStamp": location['timeStamp'],
"altitude": location['altitude'],
"horizontalAccuracy": location['horizontalAccuracy'],
"verticalAccuracy": location['verticalAccuracy'],
"batteryLevel": status['batteryLevel'],
"deviceDisplayName": status['deviceDisplayName'],
"deviceStatus": status['deviceStatus'],
"name": status['name']
}
response = iCloudReturnData(**data)
return response
logger.info("Location payload is None for device=%s", self.selected_device.name)
return None
logger.warning(
"No iCloud device matched SELECTED_DEVICE_ID='%s' or SELECTED_DEVICE_NAME='%s'.",
self.selected_device_id,
self.selected_device_name,
)
return None
def start(self):
self.running = True
def stop(self):
self.running = False
def _no_location_backoff_seconds(self, base_interval: int) -> int:
schedule = [15, 30, 60, 120, 300]
idx = min(self._no_location_streak - 1, len(schedule) - 1)
return max(base_interval, schedule[idx])
def _auth_backoff_seconds(self, base_interval: int) -> int:
schedule = [15, 30, 60, 120, 300]
idx = min(self._auth_error_streak - 1, len(schedule) - 1)
return max(base_interval, schedule[idx])
async def run_monitor(self, interval=60):
"""Runs the monitor loop."""
if not await self.authenticate():
return
if not self.running:
self.start()
while self.running:
sleep_seconds = interval
try:
device_data = await self.get_location()
if device_data is not None:
self._no_location_streak = 0
self._auth_error_streak = 0
print(f"{device_data.timeStamp} - Location: {device_data.latitude}, {device_data.longitude}")
await self.queue.put(device_data)
else:
self._no_location_streak += 1
sleep_seconds = self._no_location_backoff_seconds(interval)
logger.info(
"No iCloud location found for device id='%s' name='%s' streak=%s next_retry=%ss",
self.selected_device_id,
self.selected_device_name,
self._no_location_streak,
sleep_seconds,
)
except AUTH_EXCEPTIONS as e:
self._auth_error_streak += 1
sleep_seconds = self._auth_backoff_seconds(interval)
logger.warning(
"iCloud auth error (%s). Re-authenticating; streak=%s next_retry=%ss",
type(e).__name__,
self._auth_error_streak,
sleep_seconds,
)
try:
await self.authenticate()
except Exception:
logger.exception("iCloud re-authentication failed")
except Exception as e:
logger.exception("iCloud monitor loop error: %s", e)
sleep_seconds = max(interval, 30)
await asyncio.sleep(sleep_seconds)

View File

@@ -0,0 +1,18 @@
import logging
import json
handler = logging.StreamHandler()
root_logger = logging.getLogger()
logger = logging.getLogger("ios-api")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=True)

View File

@@ -0,0 +1,213 @@
from pymobiledevice3.services.dvt.instruments.location_simulation_base import (
LocationSimulationBase,
)
from pymobiledevice3.services.dvt.instruments.location_simulation import (
LocationSimulation,
)
class LocationSimulationQueue(LocationSimulation):
def __init__(self, dvt, context: LocationSimulationState):
super().__init__(dvt)
self.context = context
async def play_queue(
self, disable_sleep: bool = False, timing_randomness_range: int = 0
) -> None:
while True:
if self.context.queue_state == "PAUSED":
await asyncio.sleep(0.1)
continue
if self.context.queue_state == "SHUTDOWN":
break
loc_id = await self.context.queue.get()
if loc_id is None:
self.context.queue.task_done()
break
location_item = self.context.queue_data.get(loc_id)
if location_item is None:
logger.warning(
"Simulation queue item missing for loc_id=%s; skipping stale entry",
loc_id,
)
self.context.queue.task_done()
continue
new_latitude = location_item.get("latitude")
new_longitude = location_item.get("longitude")
new_delay = location_item.get("delay")
new_delay = 0 if new_delay is None else new_delay
new_start = location_item.get("start")
current_location_item = self.context.queue_data.get(self.context.loc_id)
current_latitude = (
current_location_item.get("latitude")
if isinstance(current_location_item, dict)
else self.context.latitude
)
current_longitude = (
current_location_item.get("longitude")
if isinstance(current_location_item, dict)
else self.context.longitude
)
current_start = (
current_location_item.get("start")
if isinstance(current_location_item, dict)
else None
)
if self.context.set_location_enabled:
if new_delay > 0 and not disable_sleep:
countdown_delay = int(round(float(new_delay)))
if timing_randomness_range > 0:
new_delay = new_delay + random.uniform(
-timing_randomness_range, timing_randomness_range
)
countdown_delay = int(round(float(new_delay)))
for i in range(max(0, countdown_delay), 0, -1):
self.context.next_move = i
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": current_latitude,
"longitude": current_longitude,
"start": current_start,
"next_move": i,
},
namespace="/",
)
await asyncio.sleep(1)
self.context.queue_data[loc_id]["start"] = self.context.queue_data[self.context.loc_id]["end"] = datetime.now(timezone.utc).isoformat()
await self.set(new_latitude, new_longitude)
self.context.loc_id = loc_id
self.context.latitude = new_latitude
self.context.longitude = new_longitude
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": self.context.latitude,
"longitude": self.context.longitude,
"start": new_start,
"next_move": None,
},
namespace="/",
)
logger.info(
"Set simulated location to %s, %s after %ss delay",
new_latitude,
new_longitude,
new_delay,
)
self.context.queue.task_done()
class LocationSimulationTestQueue(LocationSimulationBase):
def __init__(self, context: LocationSimulationState):
super().__init__()
self.context = context
def __enter__(self):
return self
def __exit__(self):
return self
async def set(self, latitude: float, longitude: float) -> None:
await asyncio.sleep(0.1)
logger.info("Simulated location set to %s, %s", latitude, longitude)
async def clear(self) -> None:
q = self.context.queue
self.context.set_location_enabled = False
self.context.queue_state = "SHUTDOWN"
while not q.empty():
try:
item = q.get_nowait()
q.task_done()
logger.info("Discarding item from queue: %s", item)
except asyncio.QueueEmpty:
break
await q.put(None)
if self.context.simulation_task is not None and not self.context.simulation_task.done():
try:
await asyncio.wait_for(self.context.simulation_task, timeout=5)
except TimeoutError:
self.context.simulation_task.cancel()
with suppress(asyncio.CancelledError):
await self.context.simulation_task
self.context.simulation_active = False
self.context.queue_state = "SHUTDOWN"
async def play_queue(
self, disable_sleep: bool = False, timing_randomness_range: int = 0
) -> None:
while True:
if self.context.queue_state == "PAUSED":
await asyncio.sleep(0.1)
continue
if self.context.queue_state == "SHUTDOWN":
break
loc_id = await self.context.queue.get()
if loc_id is None:
self.context.queue.task_done()
break
location_item = self.context.queue_data.get(loc_id)
if location_item is None:
logger.warning(
"Test simulation queue item missing for loc_id=%s; skipping stale entry",
loc_id,
)
self.context.queue.task_done()
continue
latitude = location_item.get("latitude")
longitude = location_item.get("longitude")
delay = location_item.get("delay")
delay = 0 if delay is None else delay
start_time = location_item.get("start")
if self.context.set_location_enabled:
if delay > 0 and not disable_sleep:
countdown_delay = int(round(float(delay)))
if timing_randomness_range > 0:
delay = delay + random.uniform(
-timing_randomness_range, timing_randomness_range
)
countdown_delay = int(round(float(delay)))
for i in range(max(0, countdown_delay), 0, -1):
self.context.next_move = i
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": self.context.latitude,
"longitude": self.context.longitude,
"next_move": i,
},
namespace="/",
)
await asyncio.sleep(1)
await self.set(latitude, longitude)
self.context.latitude = latitude
self.context.longitude = longitude
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": self.context.latitude,
"longitude": self.context.longitude,
"next_move": None,
},
namespace="/",
)
logger.info(
"Set simulated location to %s, %s after %ss delay",
latitude,
longitude,
delay,
)
self.context.queue.task_done()

View File

@@ -0,0 +1,64 @@
from typing import Optional, Dict
from pydantic import BaseModel
class SimulationStatusData(BaseModel):
latitude: float
longitude: float
start: float
end: Optional[float]
next_move: Optional[float]
class SimulationStatus(BaseModel):
status: bool
data: Optional[SimulationStatusData]
class SimulationRequestData(BaseModel):
latitude: float
longitude: float
delay: int = 0
start: Optional[str] = None
end: Optional[str] = None
class SimulationRequest(BaseModel):
status: bool
data: Optional[SimulationRequestData]
class SimulationRequestResponseData(BaseModel):
loc_id: str
latitude: float
longitude: float
delay: int = 0
start: Optional[str] = None
end: Optional[str] = None
class SimulationQueueList(BaseModel):
data: Optional[SimulationRequestResponseData]
class SimulationRequestResponse(BaseModel):
status: bool
data: Optional[SimulationRequestResponseData]
class SimulationQueueDict(BaseModel):
location_id: Dict[str, SimulationRequestResponseData]
class iCloudLocationData(BaseModel):
latitude: float
longitude: float
timestamp: str
class iCloudReturnData(BaseModel):
latitude: float
longitude: float
timeStamp: int
altitude: float
horizontalAccuracy: float
verticalAccuracy: float
batteryLevel: float
deviceDisplayName: str
deviceStatus: int
name: str

View File

@@ -0,0 +1,379 @@
""" Tunnel Functions"""
@self._app.get("/start-tunnel")
async def start_tunnel(
udid: Optional[str] = self.context.udid,
ip: Optional[str] = None,
connection_type: Optional[str] = None,
) -> fastapi.Response:
udid_tunnels = [
t.tunnel
for t in self._tunneld_core.tunnel_tasks.values()
if t.udid == udid and t.tunnel is not None
]
if len(udid_tunnels) > 0:
self.context.udid = udid
data = {
"interface": udid_tunnels[0].interface,
"port": udid_tunnels[0].port,
"address": udid_tunnels[0].address,
}
return generate_http_response(data)
queue = asyncio.Queue()
created_task = False
try:
if not created_task and connection_type in ("usbmux", None):
task_identifier = f"usbmux-{udid}"
try:
async with await create_using_usbmux(udid) as lockdown:
service = await CoreDeviceTunnelProxy.create(lockdown)
task = asyncio.create_task(
self._tunneld_core.start_tunnel_task(
task_identifier,
service,
protocol=TunnelProtocol.TCP,
queue=queue,
),
name=f"start-tunnel-task-{task_identifier}",
)
self._tunneld_core.tunnel_tasks[task_identifier] = TunnelTask(
task=task, udid=udid
)
created_task = True
except ConnectionFailedError, InvalidServiceError, MuxException:
pass
if connection_type in ("usb", None):
for rsd in await get_rsds(udid=udid):
rsd_ip = rsd.service.address[0]
if ip is not None and rsd_ip != ip:
await rsd.close()
continue
task = asyncio.create_task(
self._tunneld_core.start_tunnel_task(
rsd_ip,
await create_core_device_tunnel_service_using_rsd(rsd),
queue=queue,
),
name=f"start-tunnel-usb-{rsd_ip}",
)
self._tunneld_core.tunnel_tasks[rsd_ip] = TunnelTask(
task=task, udid=rsd.udid
)
created_task = True
if not created_task and connection_type in ("wifi", None):
for remotepairing in await get_remote_pairing_tunnel_services(
udid=udid
):
remotepairing_ip = remotepairing.hostname
if ip is not None and remotepairing_ip != ip:
await remotepairing.close()
continue
task = asyncio.create_task(
self._tunneld_core.start_tunnel_task(
remotepairing_ip, remotepairing, queue=queue
),
name=f"start-tunnel-wifi-{remotepairing_ip}",
)
self._tunneld_core.tunnel_tasks[remotepairing_ip] = TunnelTask(
task=task, udid=remotepairing.remote_identifier
)
created_task = True
except Exception as e:
return fastapi.Response(
status_code=501,
content=json.dumps(
{
"error": {
"exception": e.__class__.__name__,
"traceback": traceback.format_exc(),
}
}
),
)
if not created_task:
return fastapi.Response(
status_code=501, content=json.dumps({"error": "task not created"})
)
tunnel: Optional[TunnelResult] = await queue.get()
if tunnel is not None:
self.context.udid = udid
data = {
"interface": tunnel.interface,
"port": tunnel.port,
"address": tunnel.address,
}
return generate_http_response(data)
else:
return fastapi.Response(
status_code=404,
content=json.dumps(
{"error": "something went wrong during tunnel creation"}
),
)
@self._app.get("/restart-tunneld")
async def restart() -> fastapi.Response:
"""Restart Tunneld"""
self._tunneld_core.clear()
await asyncio.sleep(2)
self._tunneld_core.start()
data = {
"operation": "restart-tunneld",
"data": True,
"message": "Restarting tunneld...",
}
return generate_http_response(data)
@self._app.get("/shutdown")
async def shutdown() -> fastapi.Response:
"""Shutdown Tunneld"""
os.kill(os.getpid(), signal.SIGINT)
data = {
"operation": "shutdown",
"data": True,
"message": "Server shutting down...",
}
return generate_http_response(data)
@self._app.get("/clear-tunnels")
async def clear_tunnels() -> fastapi.Response:
"""Clear all tunnels"""
self._tunneld_core.clear()
data = {
"operation": "clear_tunnels",
"data": True,
"message": "Cleared tunnels...",
}
return generate_http_response(data)
@self._app.get("/cancel")
async def cancel_tunnel(udid: str) -> fastapi.Response:
"""Cancel a tunnel"""
self._tunneld_core.cancel(udid=udid)
data = {
"operation": "cancel",
"udid": udid,
"data": True,
"message": f"tunnel {udid} Canceled ...",
}
return generate_http_response(data)
"""Simulation Functions"""
@self._app.get("/start-simulation")
async def app_start_simulation() -> fastapi.Response:
logger.info("Simulation Start Requested ")
if (
self.context.simulation_task is None
or self.context.simulation_task.done()
):
await start_icloud_monitor()
self.context.simulation_active = True
self.context.simulation_task = asyncio.create_task(
start_simulation_queue(),
name="location-simulation-worker",
)
data = {"status": "started", "message": "Simulation started"}
else:
data = {"status": "error", "message": "Simulation already running"}
return generate_http_response(data)
@self._app.get("/start-icloud-monitor")
async def app_start_icloud_monitor() -> fastapi.Response:
await start_icloud_monitor()
data = {
"status": "started",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
return generate_http_response(data)
@self._app.get("/stop-icloud-monitor")
async def app_stop_icloud_monitor() -> fastapi.Response:
await end_icloud_monitor()
data = {
"status": "stopped",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
return generate_http_response(data)
@self._app.get("/icloud-monitor-status")
async def app_icloud_monitor_status() -> fastapi.Response:
data = {
"status": "ok",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
return generate_http_response(data)
@self._app.post("/add-location")
async def app_add_location(data: SimulationRequestData) -> fastapi.Response:
"""Add a location to the simulation queue"""
logger.info("Request to add new location to queue")
loc_id = str(uuid.uuid4())
latitude = (
data.get("latitude")
if isinstance(data, dict)
else getattr(data, "latitude", None)
)
longitude = (
data.get("longitude")
if isinstance(data, dict)
else getattr(data, "longitude", None)
)
delay = (
data.get("delay", 0)
if isinstance(data, dict)
else getattr(data, "delay", 0)
)
try:
delay = parse_delay_seconds(delay)
except ValueError as e:
return generate_http_response(
{"status": "error", "message": str(e)},
status_code=400,
)
if latitude is not None and longitude is not None:
logger.info(
"Adding location %s (%s, %s) with %s delay to the queue",
loc_id,
latitude,
longitude,
delay,
)
accrued_delay = 0
if self.context.queue_data:
accrued_delay = sum(
parse_delay_seconds(item.get("delay", 0))
for item in self.context.queue_data.values()
)
now_time = datetime.now(timezone.utc)
new_time = (
now_time
+ timedelta(seconds=accrued_delay)
+ timedelta(seconds=delay)
)
start_time = new_time.isoformat()
location_item = {
"loc_id": loc_id,
"latitude": latitude,
"longitude": longitude,
"delay": delay,
"start": start_time,
"status": "queued",
}
resp = {
"status": "added",
"message": f"Location {loc_id} added to the queue",
"item": location_item,
}
await self.context.queue.put(loc_id)
add_item(loc_id, location_item)
logger.info("Location %s added to the queue", loc_id)
else:
resp = {"status": "error", "message": "Invalid location data"}
return generate_http_response(resp)
@self._app.get("/clear-queue")
async def app_clear_queue() -> fastapi.Response:
"""Clear the simulation queue"""
logger.info("Simulation Start Requested ")
await empty_simulation_queue()
data = {"status": "cleared", "message": "Simulation cleared"}
return generate_http_response(data)
@self._app.get("/pause-queue")
async def app_pause_queue() -> fastapi.Response:
"""Pause the simulation queue"""
await pause_simulation_queue()
data = {"status": "paused", "message": "Simulation paused"}
return generate_http_response(data)
@self._app.get("/resume-queue")
async def app_resume_queue() -> fastapi.Response:
"""Resume the simulation queue"""
await resume_simulation_queue()
data = {"status": "resumed", "message": "Simulation resumed"}
return generate_http_response(data)
@self._app.get("/end-simulation")
async def app_end_simulation() -> fastapi.Response:
"""End the simulation queue"""
logger.info("End location simulation request")
end_task = asyncio.create_task(
end_simulation_queue(), name="end-simulation-worker"
)
result = await end_task
data = {"status": result, "message": "Simulation ended"}
return generate_http_response(data)
"""Status Functions"""
@self._app.get("/")
async def list_tunnels() -> dict[str, list[dict]]:
"""Retrieve the available tunnels and format them as {UUID: TUNNEL_ADDRESS}"""
tunnels = {}
for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items():
if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
continue
if active_tunnel.udid not in tunnels:
tunnels[active_tunnel.udid] = []
tunnels[active_tunnel.udid].append(
{
"tunnel-address": active_tunnel.tunnel.address,
"tunnel-port": active_tunnel.tunnel.port,
"interface": ip,
}
)
return tunnels
@self._app.get("/device-info")
async def device_info():
"""Get device information"""
tunnels = {}
for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items():
if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
continue
if active_tunnel.udid not in tunnels:
tunnels[active_tunnel.udid] = {}
try:
lockdown = await create_using_usbmux(
serial=active_tunnel.udid, autopair=False
)
tunnels[active_tunnel.udid] = iterate_multidim(lockdown.all_values)
except Exception as e:
logger.error(
f"Failed to create lockdown session for device {active_tunnel.udid}: {e}"
)
continue
return tunnels
@self._app.get("/device-name")
async def rsd_info():
"""Get rsd information"""
device_name = await get_device_name()
return generate_http_response(device_name)
@self._app.get("/rsd-info")
async def rsd_info():
"""Get rsd information"""
rsd_info = {}
if self.context.tunnel is None:
await get_tun()
if self.context.tunnel is not None:
rsd_info = self.context.tunnel.peer_info
return generate_http_response(rsd_info)
@self._app.get("/hello")
async def hello() -> fastapi.Response:
data = {"message": "Hello, I'm alive"}
return generate_http_response(data)
@self._app.get("/context-status")
async def app_context_status() -> fastapi.Response:
data = get_status()
return generate_http_response(data)

View File

@@ -0,0 +1,425 @@
""" Socket.IO Functions"""
async def sio_send_status(sid):
"""Send Current Status"""
await self.context.sio.emit(
"status", jsonable_encoder(get_status()), namespace="/", to=sid
)
"""Socket.IO Connection Events"""
@self.context.sio.event
async def connect(sid, environ):
"""Client connection event handler."""
self.context.connected_clients.add(sid)
logger.info("Client connected: %s", sid)
await sio_send_status(sid)
return "%s connected" % sid
@self.context.sio.event
async def disconnect(sid):
"""Client disconnection event handler."""
self.context.connected_clients.discard(sid)
logger.info("Client disconnected: %s", sid)
""" Socket.IO Request Events """
@self.context.sio.event
async def request_update(sid):
status_update = jsonable_encoder(get_status())
logger.info("Update request from %s sending %s", sid, status_update)
return status_update
@self.context.sio.event
async def message(sid, data):
logger.info("Received message from %s: %s", sid, data)
return True, "Message received"
# await self.context.sio.emit("message", f"Received message from {sid}: {data}", namespace="/")
""" Device Control"""
@self.context.sio.event
async def device_control(sid, data):
"""Device Control"""
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
delay = (
data.get("delay")
if isinstance(data, dict)
else getattr(data, "delay", None)
)
if delay is None:
delay = 5
match command:
case "shutdown":
""" Shutdown the device"""
logger.info(
"Shutdown command received from %s with delay %s", sid, delay
)
await device_shutdown(delay)
return {
"command": "shutdown",
"status": "success",
"message": f"Device shutdown initiated with {delay} seconds delay",
}
case "reboot":
""" Reboot the device"""
logger.info(
"Reboot command received from %s with delay %s", sid, delay
)
await device_reboot(delay)
return {
"command": "reboot",
"status": "success",
"message": f"Device reboot initiated with {delay} seconds delay",
}
case _:
return {
"command": command,
"status": "error",
"message": f"Invalid command: {command}",
}
@self.context.sio.event
async def simulation_control(sid, data):
"""Simulation Control"""
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
logger.info(
"Simulation Control command: %s requested from %s", command, sid
)
try:
match command:
case "add":
""" Add a location to the simulation queue"""
loc_id = str(uuid.uuid4())
latitude = (
data.get("latitude")
if isinstance(data, dict)
else getattr(data, "latitude", None)
)
longitude = (
data.get("longitude")
if isinstance(data, dict)
else getattr(data, "longitude", None)
)
delay = (
data.get("delay", 0)
if isinstance(data, dict)
else getattr(data, "delay", 0)
)
try:
delay = parse_delay_seconds(delay)
except ValueError as e:
return {
"command": command,
"status": "error",
"message": str(e),
}
if latitude is not None and longitude is not None:
logger.info(
"Adding location %s (%s, %s) with %s delay to the queue",
loc_id,
latitude,
longitude,
delay,
)
accrued_delay = 0
if self.context.queue_data:
accrued_delay = sum(
parse_delay_seconds(item.get("delay", 0))
for item in self.context.queue_data.values()
)
now_time = datetime.now(timezone.utc)
new_time = (
now_time
+ timedelta(seconds=accrued_delay)
+ timedelta(seconds=delay)
)
start_time = new_time.isoformat()
coords = f"{latitude}, {longitude}"
rev_geocode = self.context.reverse_geocode(coords)
if rev_geocode:
address = rev_geocode.address
else:
address = f"{latitude}, {longitude}"
location_item = {
"loc_id": loc_id,
"latitude": latitude,
"longitude": longitude,
"delay": delay,
"start": start_time,
"address": address,
}
ack = {
"command": command,
"status": "added",
"message": f"Location {loc_id} added to the queue",
"item": location_item,
}
await self.context.queue.put(loc_id)
add_item(loc_id, location_item)
logger.info("Location %s added to the queue", loc_id)
return ack
else:
logger.warning(
"Invalid location data received from %s: %s", sid, data
)
return {
"command": command,
"status": "error",
"message": "Invalid location data",
"data": location_item,
}
case "clear":
""" Clear the simulation queue"""
await empty_simulation_queue()
return {
"command": command,
"status": "cleared",
"message": "Simulation cleared",
}
case "pause":
""" Pause the simulation queue"""
await pause_simulation_queue()
return {
"command": command,
"status": "paused",
"message": "Simulation paused",
}
case "resume":
""" Resume the simulation queue"""
await resume_simulation_queue()
return {
"command": command,
"status": "resumed",
"message": "Simulation resumed",
}
case "end":
""" End the simulation queue"""
logger.info("End location simulation request from %s", sid)
end_task = asyncio.create_task(
end_simulation_queue(), name="end-simulation-worker"
)
result = await end_task
simstatus = not result
return {
"command": command,
"status": simstatus,
"message": "Simulation ended",
}
case "start":
""" Start the simulation queue"""
logger.info("Start location simulation request from %s", sid)
if (
self.context.simulation_task is None
or self.context.simulation_task.done()
):
await start_icloud_monitor()
self.context.simulation_active = True
self.context.queue_state = "RUNNING"
self.context.simulation_task = asyncio.create_task(
start_simulation_queue(),
name="location-simulation-worker",
)
return {
"command": command,
"status": self.context.queue_state,
"message": "Simulation started",
}
else:
return {
"command": command,
"status": "error",
"message": "Simulation already running",
}
case _:
logger.warning(
"Invalid command received from %s: %s", sid, command
)
return {"status": "error", "message": "Invalid command"}
finally:
await sio_send_status(sid)
@self.context.sio.event
async def icloud_monitor_control(sid, data):
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
logger.info(
"iCloud Monitor control command: %s requested from %s", command, sid
)
try:
match command:
case "start":
await start_icloud_monitor()
return {
"command": command,
"status": "running",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
case "stop":
await end_icloud_monitor()
return {
"command": command,
"status": "stopped",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
case "status":
return {
"command": command,
"status": "ok",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
case _:
return {
"command": command,
"status": "error",
"message": "Invalid command",
}
finally:
await sio_send_status(sid)
""" Tunnel Control """
@self.context.sio.event
async def tunneld_control(sid, data):
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
logger.info("Tunneld Control command: %s requested from %s", command, sid)
match command:
case "start":
"""Start Tunneld"""
logger.info("Start tunneld request from %s: %s", sid, data)
try:
self._tunneld_core.start()
logger.info("Tunneld started successfully")
return {
"status": "running",
"message": "Tunneld started successfully",
}
except Exception as e:
logger.error("Error starting tunneld: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error starting tunneld: {e}",
}
case "start-watcher":
""" Start Tunneld Watcher """
logger.info("Start tunneld watcher request from %s: %s", sid, data)
await start_tunnel_watcher()
return {
"status": "running",
"message": "Tunneld watcher started successfully",
}
case "end-watcher":
""" End Tunneld Watcher """
logger.info("End tunneld watcher request from %s: %s", sid, data)
await end_tunnel_watcher()
return {
"status": "stopped",
"message": "Tunneld watcher stopped successfully",
}
case "shutdown":
"""Shutdown Tunneld"""
logger.info("Shutdown tunneld request from %s: %s", sid, data)
try:
os.kill(os.getpid(), signal.SIGINT)
return {
"command": command,
"status": "Success",
"message": "Server shutting down...",
}
except Exception as e:
logger.error("Error shutting down tunneld: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error shutting down tunneld: {e}",
}
case "clear":
"""Clear all tunnels"""
logger.info("Clearing tunnels...")
try:
self._tunneld_core.clear()
return {
"command": command,
"status": "Success",
"message": "Cleared tunnels...",
}
except Exception as e:
logger.error("Error clearing tunnels: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error clearing tunnels: {e}",
}
case "cancel":
"""Cancel a tunnel"""
logger.info("Canceling tunnel request from %s: %s", sid, data)
try:
udid = (
data.get("udid")
if isinstance(data, dict)
else getattr(data, "udid", self.context.udid)
)
if udid is None:
udid = self.context.udid
self._tunneld_core.cancel(udid=udid)
return {
"command": command,
"status": "Success",
"udid": udid,
"message": f"tunnel {udid} Canceled ...",
}
except Exception as e:
logger.error("Error canceling tunnel: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error canceling tunnel: {e}",
}
case _:
return {
"command": command,
"status": "error",
"message": f"Unknown operation: {command}",
}

File diff suppressed because it is too large Load Diff

125
uv.lock generated
View File

@@ -93,24 +93,36 @@ name = "back-end"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "click" },
{ name = "daemonize" },
{ name = "fastapi" },
{ name = "geopy" },
{ name = "pydantic" },
{ name = "pyicloud" },
{ name = "pymobiledevice3" },
{ name = "python-dotenv" },
{ name = "python-socketio" },
{ name = "sqlalchemy" },
{ name = "sqlalchemy-orm" },
{ name = "typer" },
{ name = "typing" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.3.1" },
{ name = "daemonize", specifier = ">=2.5.0" },
{ name = "fastapi", specifier = "==0.135.1" },
{ name = "geopy", specifier = "==2.4.1" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pyicloud", specifier = ">=2.4.1" },
{ name = "pymobiledevice3", specifier = "==9.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "python-socketio", specifier = "==5.16.1" },
{ name = "sqlalchemy", specifier = ">=2.0.48" },
{ name = "sqlalchemy-orm", specifier = ">=1.2.10" },
{ name = "typer", specifier = ">=0.24.1" },
{ name = "typing", specifier = "==3.10.0.0" },
{ name = "uvicorn", specifier = "==0.41.0" },
]
@@ -428,6 +440,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/ab/d0fa89cc4b982800dd88daa799612f11642bf9393851715d9eaeba3cfcac/fido2-2.1.1-py3-none-any.whl", hash = "sha256:f85c16c8084abf6530b6c6ec3a0cf8575943321842e06916686943a8b784182c", size = 226945, upload-time = "2026-01-19T11:08:29.675Z" },
]
[[package]]
name = "geographiclib"
version = "2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/78/4892343230a9d29faa1364564e525307a37e54ad776ea62c12129dbba704/geographiclib-2.1.tar.gz", hash = "sha256:6a6545e6262d0ed3522e13c515713718797e37ed8c672c31ad7b249f372ef108", size = 37004, upload-time = "2025-08-21T21:34:26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b3/802576f2ea5dcb48501bb162e4c7b7b3ca5654a42b2c968ef98a797a4c79/geographiclib-2.1-py3-none-any.whl", hash = "sha256:e2a873b9b9e7fc38721ad73d5f4e6c9ed140d428a339970f505c07056997d40b", size = 40740, upload-time = "2025-08-21T21:34:24.955Z" },
]
[[package]]
name = "geopy"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "geographiclib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/fd/ef6d53875ceab72c1fad22dbed5ec1ad04eb378c2251a6a8024bad890c3b/geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1", size = 117625, upload-time = "2023-11-23T21:49:32.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" },
]
[[package]]
name = "gpxpy"
version = "1.6.2"
@@ -437,6 +470,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/9f/62df6c1e52462bdd04275b36cec49efa9e8af7e7b834499eb288f73dcfbc/gpxpy-1.6.2-py3-none-any.whl", hash = "sha256:289bc2d80f116c988d0a1e763fda22838f83005573ece2bbc6521817b26fb40a", size = 42649, upload-time = "2023-11-29T17:25:35.76Z" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -491,6 +547,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
]
[[package]]
name = "inflection"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
]
[[package]]
name = "inquirer3"
version = "0.6.1"
@@ -728,6 +793,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "opack2"
version = "0.0.1"
@@ -1354,6 +1428,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
{ url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
[[package]]
name = "sqlalchemy-orm"
version = "1.2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cached-property" },
{ name = "inflection" },
{ name = "sqlalchemy" },
{ name = "typing-inspect" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/98/b0451ae949f8b16287965d4fc4a180fae50a78d9b186300e135ec22e1883/sqlalchemy-orm-1.2.10.tar.gz", hash = "sha256:7ab46d2a54a429d4fd384df9a37ad639dc87ff93be5205ed649c5ca4dad164bb", size = 21846, upload-time = "2023-05-20T09:18:19.064Z" }
[[package]]
name = "srp"
version = "1.0.22"
@@ -1491,6 +1603,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspect"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"