docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
commit
85e9a53750
65 files changed
+18603
No files matched your search
@@ -0,0 +1,312 @@
|
||||
"""配置存储:config.json 的读写与默认值(见 docs/PROTOCOL.md 2.3)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import protocol
|
||||
|
||||
DEFAULT_TILE_SOURCES: dict[str, dict[str, Any]] = {
|
||||
"amap": {
|
||||
"name": "高德矢量(带标注)",
|
||||
"url": "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}",
|
||||
"subdomains": ["1", "2", "3", "4"],
|
||||
"max_zoom": 18,
|
||||
"attribution": "© 高德地图",
|
||||
"coord": "gcj02",
|
||||
},
|
||||
"amap_sat": {
|
||||
"name": "高德卫星(含路网标注)",
|
||||
"url": "https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
|
||||
"subdomains": ["1", "2", "3", "4"],
|
||||
"max_zoom": 18,
|
||||
"attribution": "© 高德地图",
|
||||
"coord": "gcj02",
|
||||
},
|
||||
"arcgis_sat": {
|
||||
"name": "全球卫星(Esri)",
|
||||
"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||
"subdomains": [],
|
||||
"max_zoom": 19,
|
||||
"attribution": "Esri, Maxar, Earthstar Geographics",
|
||||
"coord": "wgs84",
|
||||
},
|
||||
"osm": {
|
||||
"name": "OpenStreetMap(国内不可达)",
|
||||
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"subdomains": [],
|
||||
"max_zoom": 19,
|
||||
"attribution": "© OpenStreetMap contributors",
|
||||
"coord": "wgs84",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"tile_source": "amap",
|
||||
"tile_sources": DEFAULT_TILE_SOURCES,
|
||||
"tile_cache_dir": "~/.ios-location-tiles",
|
||||
"tile_cache_max_mb": 1024,
|
||||
"send_hz": protocol.DEFAULT_SEND_HZ,
|
||||
"default_speed_scale": 1.0,
|
||||
"mode_defaults": dict(protocol.MODE_DEFAULTS),
|
||||
"osrm_url": "https://router.project-osrm.org/route/v1",
|
||||
"last_device": None,
|
||||
"window": {"width": 1360, "height": 860},
|
||||
# 真实感模拟:GPS 漂移与速度浮动(校园跑等 App 测试用)
|
||||
"drift_enabled": True,
|
||||
"drift_m": 4.0,
|
||||
"speed_jitter_enabled": True,
|
||||
"speed_jitter": 0.08,
|
||||
# 真实感进阶:发送节奏抖动 / 速度由位移推算 / 粗差注入
|
||||
"timing_jitter_enabled": True,
|
||||
"derive_speed": True,
|
||||
"gross_error_enabled": False,
|
||||
"gross_error_probability": 0.0,
|
||||
"gross_error_m": 30.0,
|
||||
}
|
||||
|
||||
_IMMUTABLE_KEYS = {"tile_sources"}
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path).expanduser()
|
||||
self._lock = threading.RLock()
|
||||
self._data: dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG)
|
||||
self._repaired = False
|
||||
self.load()
|
||||
|
||||
# ------------ 持久化 ------------
|
||||
def load(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.path.exists():
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
self._merge(raw)
|
||||
except Exception:
|
||||
backup = self.path.with_suffix(".bak.json")
|
||||
try:
|
||||
self.path.replace(backup)
|
||||
except OSError:
|
||||
pass
|
||||
# 归一化必须放在补齐瓦片源之前:文件里可能是被写坏的字符串等非法值
|
||||
self._normalize()
|
||||
sources = self._data.get("tile_sources")
|
||||
if not isinstance(sources, dict):
|
||||
sources = copy.deepcopy(DEFAULT_TILE_SOURCES)
|
||||
for key, value in DEFAULT_TILE_SOURCES.items():
|
||||
sources.setdefault(key, copy.deepcopy(value))
|
||||
self._data["tile_sources"] = sources
|
||||
if self._data.get("tile_source") not in sources:
|
||||
self._data["tile_source"] = "amap"
|
||||
self._repaired = True
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
def _normalize(self) -> None:
|
||||
"""把每一项都过一遍校验,非法项回退到默认值。"""
|
||||
for key, default in DEFAULT_CONFIG.items():
|
||||
if key not in self._data:
|
||||
self._data[key] = copy.deepcopy(default)
|
||||
continue
|
||||
try:
|
||||
self._data[key] = self._validate(key, self._data[key])
|
||||
except protocol.AppError:
|
||||
self._data[key] = copy.deepcopy(default)
|
||||
self._repaired = True
|
||||
try:
|
||||
self._data["tile_sources"] = self._validate("tile_sources", self._data.get("tile_sources"))
|
||||
except protocol.AppError:
|
||||
self._data["tile_sources"] = copy.deepcopy(DEFAULT_TILE_SOURCES)
|
||||
self._repaired = True
|
||||
if self._data.get("tile_source") not in (self._data.get("tile_sources") or {}):
|
||||
self._data["tile_source"] = "amap"
|
||||
self._repaired = True
|
||||
|
||||
def _merge(self, incoming: dict[str, Any]) -> None:
|
||||
for key, value in incoming.items():
|
||||
if key in _IMMUTABLE_KEYS and isinstance(value, dict):
|
||||
merged = copy.deepcopy(DEFAULT_TILE_SOURCES)
|
||||
for sub_key, sub_value in value.items():
|
||||
if isinstance(sub_value, dict):
|
||||
merged[sub_key] = {**merged.get(sub_key, {}), **sub_value}
|
||||
self._data[key] = merged
|
||||
else:
|
||||
self._data[key] = value
|
||||
|
||||
def save(self) -> None:
|
||||
with self._lock:
|
||||
payload = copy.deepcopy(self._data)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
# ------------ 访问 ------------
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
with self._lock:
|
||||
return copy.deepcopy(self._data.get(key, default))
|
||||
|
||||
def update(self, patch: dict[str, Any], persist: bool = True) -> dict[str, Any]:
|
||||
if not isinstance(patch, dict):
|
||||
raise protocol.AppError("配置必须是 JSON 对象", "bad_config")
|
||||
with self._lock:
|
||||
allowed = set(DEFAULT_CONFIG) | {"tile_sources"}
|
||||
unknown = [k for k in patch if k not in allowed]
|
||||
if unknown:
|
||||
raise protocol.AppError(
|
||||
"不支持的配置项:" + "、".join(unknown), "bad_config"
|
||||
)
|
||||
validated = {}
|
||||
incoming = patch.get("tile_sources")
|
||||
self._incoming_sources = incoming if isinstance(incoming, dict) else None
|
||||
try:
|
||||
for key, value in patch.items():
|
||||
validated[key] = self._validate(key, value)
|
||||
finally:
|
||||
self._incoming_sources = None
|
||||
self._merge(validated)
|
||||
if persist:
|
||||
self.save()
|
||||
return self.snapshot()
|
||||
|
||||
def _validate(self, key: str, value: Any) -> Any:
|
||||
"""逐项做类型与范围校验:非法值必须被拒绝,绝不能写进运行态。
|
||||
|
||||
历史上 `tile_sources` 被写成字符串会导致所有瓦片请求 500(地图永久空白),
|
||||
`send_hz` 被写成 NaN 会让 welcome 变成非法 JSON,因此这里逐项严格校验。
|
||||
"""
|
||||
if key == "tile_sources":
|
||||
if not isinstance(value, dict) or not value:
|
||||
raise protocol.AppError("tile_sources 必须是「源名称 → 配置」的对象", "bad_config")
|
||||
cleaned: dict[str, Any] = {}
|
||||
for name, item in value.items():
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise protocol.AppError("瓦片源名称必须是非空字符串", "bad_config")
|
||||
if not isinstance(item, dict):
|
||||
raise protocol.AppError(f"瓦片源 {name} 的配置必须是对象", "bad_config")
|
||||
url = item.get("url")
|
||||
if url is not None and (not isinstance(url, str) or "{z}" not in url):
|
||||
raise protocol.AppError(
|
||||
f"瓦片源 {name} 的 url 必须包含 {{z}}/{{x}}/{{y}} 占位符", "bad_config"
|
||||
)
|
||||
subs = item.get("subdomains") or []
|
||||
if not isinstance(subs, list) or any(not isinstance(s, (str, int)) for s in subs):
|
||||
raise protocol.AppError(f"瓦片源 {name} 的 subdomains 必须是字符串数组", "bad_config")
|
||||
if "max_zoom" in item:
|
||||
zoom = protocol.finite(item["max_zoom"], f"{name}.max_zoom")
|
||||
if not (0 <= zoom <= 22):
|
||||
raise protocol.AppError(f"瓦片源 {name} 的 max_zoom 需在 0~22 之间", "bad_config")
|
||||
item = {**item, "max_zoom": int(zoom)}
|
||||
cleaned[name] = {**DEFAULT_TILE_SOURCES.get(name, {}), **item}
|
||||
return cleaned
|
||||
if key == "tile_source":
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise protocol.AppError("tile_source 必须是非空字符串", "bad_config")
|
||||
# 同一请求里若同时提供了 tile_sources,就以新集合为准(否则自定义源会被误拒)
|
||||
incoming = getattr(self, "_incoming_sources", None)
|
||||
known = incoming if isinstance(incoming, dict) and incoming else (
|
||||
self._data.get("tile_sources") or DEFAULT_TILE_SOURCES
|
||||
)
|
||||
if value not in known:
|
||||
raise protocol.AppError(f"未知的瓦片源:{value}", "bad_config")
|
||||
return value
|
||||
if key == "tile_cache_dir":
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise protocol.AppError("tile_cache_dir 必须是非空路径字符串", "bad_config")
|
||||
return value
|
||||
if key == "tile_cache_max_mb":
|
||||
number = protocol.finite(value, "tile_cache_max_mb")
|
||||
if not (64 <= number <= 102400):
|
||||
raise protocol.AppError("瓦片缓存上限需在 64 ~ 102400 MB 之间", "bad_config")
|
||||
return int(number)
|
||||
if key == "send_hz":
|
||||
number = protocol.finite(value, "send_hz")
|
||||
if not (protocol.MIN_SEND_HZ <= number <= protocol.MAX_SEND_HZ):
|
||||
raise protocol.AppError(
|
||||
f"下发频率需在 {protocol.MIN_SEND_HZ} ~ {protocol.MAX_SEND_HZ} Hz 之间", "bad_config"
|
||||
)
|
||||
return number
|
||||
if key == "default_speed_scale":
|
||||
return protocol.validate_speed_scale(value)
|
||||
if key in ("drift_enabled", "speed_jitter_enabled", "timing_jitter_enabled",
|
||||
"derive_speed", "gross_error_enabled"):
|
||||
if not isinstance(value, bool):
|
||||
raise protocol.AppError(f"{key} 必须是 true/false", "bad_config")
|
||||
return value
|
||||
if key == "gross_error_probability":
|
||||
number = protocol.finite(value, "粗差概率")
|
||||
if not (0.0 <= number <= 0.5):
|
||||
raise protocol.AppError("粗差概率需在 0 ~ 0.5 之间", "bad_config")
|
||||
return number
|
||||
if key == "gross_error_m":
|
||||
number = protocol.finite(value, "粗差幅度")
|
||||
if not (1.0 <= number <= 500.0):
|
||||
raise protocol.AppError("粗差幅度需在 1 ~ 500 米之间", "bad_config")
|
||||
return number
|
||||
if key == "drift_m":
|
||||
number = protocol.finite(value, "漂移半径")
|
||||
if not (0.0 <= number <= 50.0):
|
||||
raise protocol.AppError("漂移半径需在 0 ~ 50 米之间", "bad_config")
|
||||
return number
|
||||
if key == "speed_jitter":
|
||||
number = protocol.finite(value, "速度浮动幅度")
|
||||
if 1.0 < number <= 100.0:
|
||||
number = number / 100.0 # 允许传百分数,但 500% 这种非法值不能变成 5%
|
||||
if not (0.0 <= number <= 0.6):
|
||||
raise protocol.AppError("速度浮动幅度需在 0 ~ 60%(或 0 ~ 0.6)之间", "bad_config")
|
||||
return number
|
||||
if key == "mode_defaults":
|
||||
if not isinstance(value, dict):
|
||||
raise protocol.AppError("mode_defaults 必须是对象", "bad_config")
|
||||
out: dict[str, float] = {}
|
||||
for mode, speed in value.items():
|
||||
if mode not in protocol.MODE_DEFAULTS:
|
||||
raise protocol.AppError(f"未知出行方式:{mode}", "bad_config")
|
||||
number = protocol.finite(speed, f"{mode} 基准速度")
|
||||
if not (0.2 <= number <= 400):
|
||||
raise protocol.AppError(f"{mode} 基准速度需在 0.2 ~ 400 m/s 之间", "bad_config")
|
||||
out[mode] = number
|
||||
return {**protocol.MODE_DEFAULTS, **out}
|
||||
if key == "osrm_url":
|
||||
if not isinstance(value, str):
|
||||
raise protocol.AppError("osrm_url 必须是字符串", "bad_config")
|
||||
return value.strip()
|
||||
if key == "last_device":
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise protocol.AppError("last_device 必须是字符串或 null", "bad_config")
|
||||
return value
|
||||
if key == "window":
|
||||
if not isinstance(value, dict):
|
||||
raise protocol.AppError("window 必须是对象", "bad_config")
|
||||
out_window: dict[str, int] = {}
|
||||
for name in ("width", "height"):
|
||||
if name in value:
|
||||
number = protocol.finite(value[name], f"window.{name}")
|
||||
if not (200 <= number <= 10000):
|
||||
raise protocol.AppError(f"window.{name} 需在 200 ~ 10000 之间", "bad_config")
|
||||
out_window[name] = int(number)
|
||||
return {**(self._data.get("window") or {}), **out_window}
|
||||
return value
|
||||
|
||||
def tile_sources(self) -> dict[str, dict[str, Any]]:
|
||||
return self.get("tile_sources") or {}
|
||||
|
||||
def tile_source(self, key: str | None = None) -> tuple[str, dict[str, Any]]:
|
||||
sources = self.tile_sources()
|
||||
wanted = key or self.get("tile_source") or "amap"
|
||||
if wanted not in sources:
|
||||
wanted = "amap"
|
||||
return wanted, sources[wanted]
|
||||
|
||||
def cache_dir(self) -> Path:
|
||||
raw = str(self.get("tile_cache_dir") or "~/.ios-location-tiles")
|
||||
return Path(raw).expanduser()
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
"""设备发现与管理。
|
||||
|
||||
优先使用 pymobiledevice3 的 Python API(import 失败时自动降级到 libimobiledevice 的
|
||||
idevice_id / ideviceinfo 命令行),保证在依赖没装好的机器上也能列出设备。
|
||||
|
||||
不丢异常:任何一步失败都写进 Device.last_error,前端只看到中文提示。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from . import protocol
|
||||
|
||||
USBMUX_TIMEOUT = 6.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
udid: str
|
||||
name: str = "iPhone"
|
||||
product_type: str = ""
|
||||
ios_version: str = ""
|
||||
ios_major: int = 0
|
||||
connection: str = "usb"
|
||||
state: str = "connected"
|
||||
tunnel: str = "idle"
|
||||
simulating: bool = False
|
||||
developer_mode: bool | None = None
|
||||
tunnel_hint: str | None = None
|
||||
last_error: str | None = None
|
||||
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"udid": self.udid,
|
||||
"name": self.name,
|
||||
"product_type": self.product_type,
|
||||
"ios_version": self.ios_version,
|
||||
"ios_major": self.ios_major,
|
||||
"connection": self.connection,
|
||||
"state": self.state,
|
||||
"tunnel": self.tunnel,
|
||||
"simulating": self.simulating,
|
||||
"developer_mode": self.developer_mode,
|
||||
"tunnel_hint": self.tunnel_hint,
|
||||
"last_error": self.last_error,
|
||||
}
|
||||
|
||||
|
||||
PRODUCT_NAMES = {
|
||||
"iPhone10,3": "iPhone X", "iPhone10,6": "iPhone X", "iPhone11,2": "iPhone XS",
|
||||
"iPhone11,8": "iPhone XR", "iPhone12,1": "iPhone 11", "iPhone12,3": "iPhone 11 Pro",
|
||||
"iPhone13,1": "iPhone 12 mini", "iPhone13,2": "iPhone 12", "iPhone14,2": "iPhone 13 Pro",
|
||||
"iPhone14,5": "iPhone 13", "iPhone14,7": "iPhone 14", "iPhone15,2": "iPhone 14 Pro",
|
||||
"iPhone15,4": "iPhone 15", "iPhone16,1": "iPhone 15 Pro", "iPhone17,1": "iPhone 16 Pro",
|
||||
"iPhone17,3": "iPhone 16", "iPhone18,1": "iPhone 17 Pro", "iPhone18,3": "iPhone 17",
|
||||
}
|
||||
|
||||
|
||||
def _ios_major(version: str) -> int:
|
||||
match = re.match(r"(\d+)", str(version or ""))
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def _friendly_name(product_type: str, fallback: str) -> str:
|
||||
return PRODUCT_NAMES.get(product_type or "", "") or fallback or "iPhone"
|
||||
|
||||
|
||||
class DeviceManager:
|
||||
"""设备列表 + 选中设备。线程安全,可被 HTTP/WS 线程并发调用。"""
|
||||
|
||||
def __init__(self, mock: bool = False, on_change: Callable[[], None] | None = None) -> None:
|
||||
self.mock = mock
|
||||
self._lock = threading.RLock()
|
||||
self._devices: dict[str, Device] = {}
|
||||
self._selected: str | None = None
|
||||
self._on_change = on_change
|
||||
self._last_signature = ""
|
||||
self._monitor: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self.idevice_id = shutil.which("idevice_id")
|
||||
self.ideviceinfo = shutil.which("ideviceinfo")
|
||||
self._backend = "unavailable"
|
||||
if mock:
|
||||
device = Device(
|
||||
udid="MOCK-0001",
|
||||
name="模拟设备 (iPhone 17 Pro)",
|
||||
product_type="iPhone18,1",
|
||||
ios_version="26.0",
|
||||
ios_major=26,
|
||||
connection="mock",
|
||||
state="connected",
|
||||
tunnel="connected",
|
||||
developer_mode=True,
|
||||
)
|
||||
self._devices[device.udid] = device
|
||||
self._selected = device.udid
|
||||
self._backend = "mock"
|
||||
|
||||
# ---------------- 生命周期 ----------------
|
||||
def start_monitor(self, interval: float = 3.0) -> None:
|
||||
if self.mock or self._monitor is not None:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._monitor = threading.Thread(target=self._monitor_loop, args=(interval,), name="device-monitor", daemon=True)
|
||||
self._monitor.start()
|
||||
|
||||
def stop_monitor(self) -> None:
|
||||
self._stop.set()
|
||||
if self._monitor is not None:
|
||||
self._monitor.join(timeout=2.0)
|
||||
self._monitor = None
|
||||
|
||||
def _monitor_loop(self, interval: float) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.refresh()
|
||||
except Exception:
|
||||
pass
|
||||
self._stop.wait(interval)
|
||||
|
||||
# ---------------- 查询 ----------------
|
||||
def _pick_default(self, devices: dict[str, "Device"]) -> str | None:
|
||||
"""从多台设备里挑一台最可能"真的能用来做虚拟定位"的。
|
||||
|
||||
排序依据(依次比较):
|
||||
1. 是否是"可用"状态(未配对/出错的排后面);
|
||||
2. USB 优先于网络连接(Wi-Fi 连接的设备常常没挂开发者镜像,且隧道不稳);
|
||||
3. iOS 版本更新的优先(新系统对 DVT 支持更完整)。
|
||||
之前只取列表第一台,导致同时插着两台设备时随机选中不可用的那台。
|
||||
"""
|
||||
if not devices:
|
||||
return None
|
||||
|
||||
def score(device: Device) -> tuple:
|
||||
usable = 0 if device.state == "connected" else 1
|
||||
usb = 0 if device.connection == "usb" else 1
|
||||
return (usable, usb, -(device.ios_major or 0))
|
||||
|
||||
return sorted(devices.values(), key=score)[0].udid
|
||||
|
||||
def refresh(self) -> list[Device]:
|
||||
if self.mock:
|
||||
return self.list_devices()
|
||||
raw_devices = self._discover()
|
||||
with self._lock:
|
||||
previous = self._devices
|
||||
devices: dict[str, Device] = {}
|
||||
for item in raw_devices:
|
||||
udid = item.get("udid")
|
||||
if not udid:
|
||||
continue
|
||||
old = previous.get(udid)
|
||||
product_type = item.get("product_type") or (old.product_type if old else "")
|
||||
device = Device(
|
||||
udid=udid,
|
||||
name=_friendly_name(product_type, item.get("name") or ""),
|
||||
product_type=product_type,
|
||||
ios_version=item.get("ios_version") or (old.ios_version if old else ""),
|
||||
ios_major=_ios_major(item.get("ios_version") or (old.ios_version if old else "")),
|
||||
connection=item.get("connection") or "usb",
|
||||
state=item.get("state") or "connected",
|
||||
tunnel=old.tunnel if old else "idle",
|
||||
simulating=old.simulating if old else False,
|
||||
developer_mode=item.get("developer_mode", old.developer_mode if old else None),
|
||||
tunnel_hint=old.tunnel_hint if old else None,
|
||||
last_error=item.get("last_error") or (old.last_error if old else None),
|
||||
raw=item.get("raw") or {},
|
||||
)
|
||||
devices[udid] = device
|
||||
if self._selected not in devices:
|
||||
self._selected = self._pick_default(devices)
|
||||
self._devices = devices
|
||||
signature = json.dumps(
|
||||
[{k: v for k, v in d.to_json().items() if k not in ("simulating",)} for d in devices.values()],
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
) + f"|{self._selected}"
|
||||
self._backend = self._backend if self._backend == "mock" else ("pymobiledevice3" if not raw_devices else self._backend)
|
||||
if signature != self._last_signature:
|
||||
self._last_signature = signature
|
||||
if self._on_change:
|
||||
try:
|
||||
self._on_change()
|
||||
except Exception:
|
||||
pass
|
||||
return self.list_devices()
|
||||
|
||||
def list_devices(self) -> list[Device]:
|
||||
with self._lock:
|
||||
return list(self._devices.values())
|
||||
|
||||
def devices_json(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"devices": [d.to_json() for d in self._devices.values()],
|
||||
"selected": self._selected,
|
||||
}
|
||||
|
||||
def get(self, udid: str | None = None) -> Device | None:
|
||||
with self._lock:
|
||||
if udid:
|
||||
return self._devices.get(udid)
|
||||
if self._selected:
|
||||
return self._devices.get(self._selected)
|
||||
return next(iter(self._devices.values()), None)
|
||||
|
||||
def get_selected(self) -> Device | None:
|
||||
return self.get(None)
|
||||
|
||||
def select(self, udid: str) -> Device:
|
||||
with self._lock:
|
||||
device = self._devices.get(udid)
|
||||
if device is None:
|
||||
raise protocol.AppError("设备不存在或已断开,请刷新设备列表", "no_device", status=404)
|
||||
self._selected = udid
|
||||
self.refresh()
|
||||
return self.get(udid) or device
|
||||
|
||||
def selected_udid(self) -> str | None:
|
||||
with self._lock:
|
||||
return self._selected
|
||||
|
||||
def mark(self, udid: str, **fields: Any) -> None:
|
||||
with self._lock:
|
||||
device = self._devices.get(udid)
|
||||
if device is None:
|
||||
return
|
||||
for key, value in fields.items():
|
||||
if hasattr(device, key):
|
||||
setattr(device, key, value)
|
||||
|
||||
@property
|
||||
def backend(self) -> str:
|
||||
return self._backend
|
||||
|
||||
# ---------------- 发现实现 ----------------
|
||||
def _discover(self) -> list[dict[str, Any]]:
|
||||
devices = self._discover_pymobiledevice3()
|
||||
if devices is not None:
|
||||
return devices
|
||||
return self._discover_cli()
|
||||
|
||||
def _discover_pymobiledevice3(self) -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
from pymobiledevice3.lockdown import create_using_usbmux # type: ignore
|
||||
from pymobiledevice3.usbmux import list_devices as usbmux_list # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
async def _collect() -> list[dict[str, Any]]:
|
||||
found = await usbmux_list()
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in found:
|
||||
udid = getattr(item, "serial", None) or getattr(item, "udid", None)
|
||||
if not udid:
|
||||
continue
|
||||
# 注意:pymobiledevice3 的 connection_type 区分大小写,必须是 "USB" / "Network"
|
||||
raw_type = str(getattr(item, "connection_type", "USB") or "USB")
|
||||
usb = raw_type.strip().lower() == "usb"
|
||||
entry: dict[str, Any] = {
|
||||
"udid": udid,
|
||||
"connection": "usb" if usb else "network",
|
||||
"state": "connected",
|
||||
"raw": {},
|
||||
}
|
||||
try:
|
||||
client = await create_using_usbmux(
|
||||
serial=udid,
|
||||
autopair=True,
|
||||
connection_type="USB" if usb else "Network",
|
||||
# 配对对话框最长等 12 秒太久了:设备已配对时不会走这里,
|
||||
# 未配对时短超时能避免 /api/devices 与 welcome 长时间阻塞。
|
||||
pair_timeout=3.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
entry["state"] = "unpaired"
|
||||
entry["last_error"] = _friendly_pair_error(exc)
|
||||
out.append(entry)
|
||||
continue
|
||||
try:
|
||||
info: dict[str, Any] = {}
|
||||
for getter in ("short_info", "all_values"):
|
||||
try:
|
||||
candidate = getattr(client, getter, None)
|
||||
if callable(candidate):
|
||||
candidate = candidate()
|
||||
if isinstance(candidate, dict) and candidate:
|
||||
info = candidate
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
entry["product_type"] = str(info.get("ProductType") or "")
|
||||
entry["ios_version"] = str(
|
||||
info.get("ProductVersion") or getattr(client, "product_version", "") or ""
|
||||
)
|
||||
entry["name"] = str(info.get("DeviceName") or "")
|
||||
entry["developer_mode"] = await _read_developer_mode(client, info)
|
||||
entry["raw"] = {
|
||||
"build": info.get("BuildVersion"),
|
||||
"model": info.get("HardwareModel"),
|
||||
"connection": raw_type,
|
||||
}
|
||||
except Exception as exc:
|
||||
entry["state"] = "unpaired"
|
||||
entry["last_error"] = _friendly_pair_error(exc)
|
||||
finally:
|
||||
try:
|
||||
closed = client.close()
|
||||
if inspect.isawaitable(closed):
|
||||
await closed
|
||||
except Exception:
|
||||
pass
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
result = asyncio.run(_collect())
|
||||
self._backend = "pymobiledevice3"
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _discover_cli(self) -> list[dict[str, Any]]:
|
||||
if not self.idevice_id:
|
||||
self._backend = "unavailable"
|
||||
return []
|
||||
self._backend = "libimobiledevice"
|
||||
try:
|
||||
output = subprocess.run(
|
||||
[self.idevice_id, "-l"], capture_output=True, text=True, timeout=USBMUX_TIMEOUT
|
||||
).stdout
|
||||
except Exception:
|
||||
return []
|
||||
devices: list[dict[str, Any]] = []
|
||||
for line in output.splitlines():
|
||||
udid = line.strip()
|
||||
if not udid or udid.startswith("Error"):
|
||||
continue
|
||||
entry: dict[str, Any] = {"udid": udid, "connection": "usb", "state": "connected", "raw": {}}
|
||||
if self.ideviceinfo:
|
||||
try:
|
||||
info = subprocess.run(
|
||||
[self.ideviceinfo, "-u", udid, "-k", "ProductVersion"],
|
||||
capture_output=True, text=True, timeout=USBMUX_TIMEOUT,
|
||||
)
|
||||
entry["ios_version"] = info.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
info = subprocess.run(
|
||||
[self.ideviceinfo, "-u", udid, "-k", "ProductType"],
|
||||
capture_output=True, text=True, timeout=USBMUX_TIMEOUT,
|
||||
)
|
||||
entry["product_type"] = info.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
info = subprocess.run(
|
||||
[self.ideviceinfo, "-u", udid, "-k", "DeviceName"],
|
||||
capture_output=True, text=True, timeout=USBMUX_TIMEOUT,
|
||||
)
|
||||
entry["name"] = info.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
entry["name"] = _friendly_name(entry.get("product_type", ""), entry.get("name", ""))
|
||||
devices.append(entry)
|
||||
return devices
|
||||
|
||||
|
||||
async def _read_developer_mode(client: Any, info: dict[str, Any]) -> bool | None:
|
||||
"""读取开发者模式状态(该接口在 11.x 是协程)。"""
|
||||
for key in ("DeveloperModeStatus", "developer_mode_status"):
|
||||
if key in info:
|
||||
return bool(info[key])
|
||||
getter = getattr(client, "get_developer_mode_status", None)
|
||||
if not callable(getter):
|
||||
return None
|
||||
try:
|
||||
result = getter()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return bool(result)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _friendly_pair_error(exc: Exception) -> str:
|
||||
text = str(exc)
|
||||
lowered = text.lower()
|
||||
if "pair" in lowered or "trust" in lowered:
|
||||
return "设备未信任此电脑:请在 iPhone 上点「信任」并输入锁屏密码"
|
||||
if "password" in lowered or "protected" in lowered:
|
||||
return "设备已锁定:请解锁 iPhone 后重试"
|
||||
return f"读取设备信息失败:{text[:120]}"
|
||||
|
||||
|
||||
def haversine_is_available() -> bool:
|
||||
return shutil.which("pymobiledevice3") is not None
|
||||
|
||||
|
||||
def pymobiledevice3_importable() -> bool:
|
||||
try:
|
||||
import pymobiledevice3 # noqa: F401
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_pymobiledevice3(timeout: float = 30.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if pymobiledevice3_importable():
|
||||
return True
|
||||
time.sleep(1.0)
|
||||
return pymobiledevice3_importable()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""地理计算:距离、方位角、弧长插值、路径加密。
|
||||
|
||||
约定:坐标一律 (lat, lng) 的浮点元组,单位为度,WGS84。
|
||||
对外 JSON 里的坐标是 [lng, lat](GeoJSON 习惯),转换在 route_engine 里做。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .protocol import EARTH_RADIUS_M, clamp, round6
|
||||
|
||||
|
||||
def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
"""两点大圆距离(米)。"""
|
||||
p1 = math.radians(lat1)
|
||||
p2 = math.radians(lat2)
|
||||
dp = p2 - p1
|
||||
dl = math.radians(lng2 - lng1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
def bearing(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
"""起点到终点的方位角(度,0=正北,顺时针)。"""
|
||||
p1 = math.radians(lat1)
|
||||
p2 = math.radians(lat2)
|
||||
dl = math.radians(lng2 - lng1)
|
||||
y = math.sin(dl) * math.cos(p2)
|
||||
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
||||
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
||||
|
||||
|
||||
def lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def lerp_point(p1: tuple[float, float], p2: tuple[float, float], t: float) -> tuple[float, float]:
|
||||
"""线性插值(小范围下与测地线插值差异可忽略)。"""
|
||||
return (lerp(p1[0], p2[0], t), lerp(p1[1], p2[1], t))
|
||||
|
||||
|
||||
def catmull_rom(
|
||||
p0: tuple[float, float],
|
||||
p1: tuple[float, float],
|
||||
p2: tuple[float, float],
|
||||
p3: tuple[float, float],
|
||||
t: float,
|
||||
) -> tuple[float, float]:
|
||||
"""Catmull-Rom 样条插值,用于把折线平滑成车道路径。"""
|
||||
t2 = t * t
|
||||
t3 = t2 * t
|
||||
lat = 0.5 * (
|
||||
(2 * p1[0])
|
||||
+ (-p0[0] + p2[0]) * t
|
||||
+ (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2
|
||||
+ (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3
|
||||
)
|
||||
lng = 0.5 * (
|
||||
(2 * p1[1])
|
||||
+ (-p0[1] + p2[1]) * t
|
||||
+ (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2
|
||||
+ (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3
|
||||
)
|
||||
return (lat, lng)
|
||||
|
||||
|
||||
def cumulative_lengths(path: list[tuple[float, float]]) -> list[float]:
|
||||
"""返回与 path 等长的累计距离数组(米),首元素为 0。"""
|
||||
acc = [0.0]
|
||||
total = 0.0
|
||||
for i in range(1, len(path)):
|
||||
total += haversine(path[i - 1][0], path[i - 1][1], path[i][0], path[i][1])
|
||||
acc.append(total)
|
||||
return acc
|
||||
|
||||
|
||||
def _binary_search(acc: list[float], value: float) -> int:
|
||||
"""返回 i 使得 acc[i] <= value < acc[i+1](value 超界时返回最后一个区间)。"""
|
||||
lo, hi = 0, len(acc) - 1
|
||||
if value <= acc[0]:
|
||||
return 0
|
||||
if value >= acc[-1]:
|
||||
return max(0, hi - 1)
|
||||
while lo < hi - 1:
|
||||
mid = (lo + hi) // 2
|
||||
if acc[mid] <= value:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return lo
|
||||
|
||||
|
||||
def interpolate(
|
||||
path: list[tuple[float, float]], acc: list[float], distance: float
|
||||
) -> tuple[tuple[float, float], float]:
|
||||
"""按弧长在路径上取点,返回 ((lat,lng), 该段内的比例 t)。"""
|
||||
if not path:
|
||||
raise ValueError("路径为空")
|
||||
if len(path) == 1:
|
||||
return path[0], 0.0
|
||||
i = _binary_search(acc, distance)
|
||||
seg = acc[i + 1] - acc[i]
|
||||
t = 0.0 if seg <= 1e-9 else clamp((distance - acc[i]) / seg, 0.0, 1.0)
|
||||
return lerp_point(path[i], path[i + 1], t), t
|
||||
|
||||
|
||||
def slice_path(
|
||||
path: list[tuple[float, float]], acc: list[float], distance: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""返回从起点到指定弧长的子路径(用于前端高亮已播放段)。"""
|
||||
if len(path) < 2:
|
||||
return list(path)
|
||||
i = _binary_search(acc, distance)
|
||||
out = path[: i + 1]
|
||||
point, _ = interpolate(path, acc, distance)
|
||||
out.append(point)
|
||||
return out
|
||||
|
||||
|
||||
def densify_segment(
|
||||
p1: tuple[float, float], p2: tuple[float, float], step_m: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""在两点之间按 step_m 加密(不含起点,含终点)。"""
|
||||
dist = haversine(p1[0], p1[1], p2[0], p2[1])
|
||||
if dist <= 1e-6:
|
||||
return [p2]
|
||||
n = max(1, int(math.ceil(dist / max(1.0, step_m))))
|
||||
return [lerp_point(p1, p2, k / n) for k in range(1, n + 1)]
|
||||
|
||||
|
||||
def densify(
|
||||
points: list[tuple[float, float]], step_m: float = 2.0, smooth: bool = False
|
||||
) -> list[tuple[float, float]]:
|
||||
"""把稀疏折线加密到约 step_m 的采样间隔;smooth=True 时用样条平滑。"""
|
||||
pts = _dedupe(points)
|
||||
if len(pts) < 2:
|
||||
return pts
|
||||
if not smooth or len(pts) < 3:
|
||||
out = [pts[0]]
|
||||
for i in range(1, len(pts)):
|
||||
out.extend(densify_segment(pts[i - 1], pts[i], step_m))
|
||||
return out
|
||||
|
||||
padded = [pts[0]] + pts + [pts[-1]]
|
||||
out = [pts[0]]
|
||||
for i in range(1, len(padded) - 2):
|
||||
p0, p1, p2, p3 = padded[i - 1], padded[i], padded[i + 1], padded[i + 2]
|
||||
seg_len = haversine(p1[0], p1[1], p2[0], p2[1])
|
||||
n = max(1, int(math.ceil(seg_len / max(1.0, step_m))))
|
||||
for k in range(1, n + 1):
|
||||
out.append(catmull_rom(p0, p1, p2, p3, k / n))
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
out: list[tuple[float, float]] = []
|
||||
for point in points:
|
||||
if not out or haversine(out[-1][0], out[-1][1], point[0], point[1]) > 0.05:
|
||||
out.append(point)
|
||||
return out
|
||||
|
||||
|
||||
def simplify(points: list[tuple[float, float]], tolerance_m: float = 1.5) -> list[tuple[float, float]]:
|
||||
"""Douglas-Peucker 简化,用于压缩轨迹体积。"""
|
||||
if len(points) < 3:
|
||||
return list(points)
|
||||
keep = [False] * len(points)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, len(points) - 1)]
|
||||
while stack:
|
||||
start, end = stack.pop()
|
||||
if end <= start + 1:
|
||||
continue
|
||||
max_dist = -1.0
|
||||
index = start
|
||||
for i in range(start + 1, end):
|
||||
d = _point_segment_distance(points[i], points[start], points[end])
|
||||
if d > max_dist:
|
||||
max_dist = d
|
||||
index = i
|
||||
if max_dist > tolerance_m:
|
||||
keep[index] = True
|
||||
stack.append((start, index))
|
||||
stack.append((index, end))
|
||||
return [p for p, k in zip(points, keep) if k]
|
||||
|
||||
|
||||
def _point_segment_distance(
|
||||
p: tuple[float, float], a: tuple[float, float], b: tuple[float, float]
|
||||
) -> float:
|
||||
"""点到线段的近似距离(米):先把经纬度投影到局部平面。"""
|
||||
lat0 = math.radians((a[0] + b[0]) / 2)
|
||||
scale_x = EARTH_RADIUS_M * math.cos(lat0) * math.pi / 180.0
|
||||
scale_y = EARTH_RADIUS_M * math.pi / 180.0
|
||||
px, py = (p[1] - a[1]) * scale_x, (p[0] - a[0]) * scale_y
|
||||
bx, by = (b[1] - a[1]) * scale_x, (b[0] - a[0]) * scale_y
|
||||
denom = bx * bx + by * by
|
||||
if denom <= 1e-12:
|
||||
return math.hypot(px, py)
|
||||
t = clamp((px * bx + py * by) / denom, 0.0, 1.0)
|
||||
return math.hypot(px - t * bx, py - t * by)
|
||||
|
||||
|
||||
def wgs84_to_gcj02_approx(lat: float, lng: float) -> tuple[float, float]:
|
||||
"""后端不参与地图显示,这里仅提供与前端一致的近似实现(用于自检)。"""
|
||||
if lng < 72.004 or lng > 137.8347 or lat < 0.8293 or lat > 55.8271:
|
||||
return lat, lng
|
||||
a = 6378245.0
|
||||
ee = 0.00669342162296594323
|
||||
dlat = _transform_lat(lng - 105.0, lat - 35.0)
|
||||
dlng = _transform_lng(lng - 105.0, lat - 35.0)
|
||||
rad_lat = lat / 180.0 * math.pi
|
||||
magic = math.sin(rad_lat)
|
||||
magic = 1 - ee * magic * magic
|
||||
sqrt_magic = math.sqrt(magic)
|
||||
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrt_magic) * math.pi)
|
||||
dlng = (dlng * 180.0) / (a / sqrt_magic * math.cos(rad_lat) * math.pi)
|
||||
return round6(lat + dlat), round6(lng + dlng)
|
||||
|
||||
|
||||
def _transform_lat(x: float, y: float) -> float:
|
||||
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
|
||||
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
|
||||
def _transform_lng(x: float, y: float) -> float:
|
||||
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
|
||||
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
|
||||
return ret
|
||||
@@ -0,0 +1,229 @@
|
||||
"""无设备模拟定位提供者(``--mock`` 模式)。
|
||||
|
||||
契约(冻结,见 docs/PROTOCOL.md 第 4 节与 docs/DEVICE_NOTES.md):
|
||||
|
||||
class MockLocationProvider:
|
||||
async def connect(self, device) -> dict
|
||||
async def set_location(self, lat, lon) -> None
|
||||
async def clear_location(self) -> None
|
||||
async def close(self) -> None
|
||||
@property
|
||||
def connected(self) -> bool
|
||||
|
||||
行为:只记录日志、绝不报错,每次下发延时 5~10ms 模拟真机往返。
|
||||
本模块**不依赖 pymobiledevice3**,任何平台都能 import。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
__all__ = ["MockLocationProvider", "MOCK_UDID", "MOCK_IOS_VERSION", "MOCK_IOS_MAJOR"]
|
||||
|
||||
logger = logging.getLogger("iosloc.location.mock")
|
||||
|
||||
#: 与 docs/PROTOCOL.md 第 4 节一致的假设备标识
|
||||
MOCK_UDID = "MOCK-0001"
|
||||
MOCK_IOS_VERSION = "26.0"
|
||||
MOCK_IOS_MAJOR = 26
|
||||
|
||||
#: 模拟单次下发往返耗时(秒),遵循任务书 5~10ms 的要求
|
||||
MOCK_LATENCY_MIN = 0.005
|
||||
MOCK_LATENCY_MAX = 0.010
|
||||
|
||||
|
||||
def _detect_mock_device_fields(device: Any) -> dict:
|
||||
"""从 lead 的 DeviceManager 返回的 Device dict 中提取字段(容错)。
|
||||
|
||||
允许 ``device`` 为 ``None`` / dict / 具有同名属性的对象。
|
||||
"""
|
||||
fields = {
|
||||
"udid": MOCK_UDID,
|
||||
"name": "模拟设备 (iPhone 17 Pro)",
|
||||
"ios_version": MOCK_IOS_VERSION,
|
||||
"ios_major": MOCK_IOS_MAJOR,
|
||||
"connection": "mock",
|
||||
}
|
||||
if device is None:
|
||||
return fields
|
||||
|
||||
def _get(key: str) -> Any:
|
||||
if isinstance(device, dict):
|
||||
return device.get(key)
|
||||
return getattr(device, key, None)
|
||||
|
||||
udid = _get("udid")
|
||||
if udid:
|
||||
fields["udid"] = str(udid)
|
||||
name = _get("name")
|
||||
if name:
|
||||
fields["name"] = str(name)
|
||||
ios_version = _get("ios_version")
|
||||
if ios_version:
|
||||
fields["ios_version"] = str(ios_version)
|
||||
ios_major = _get("ios_major")
|
||||
if isinstance(ios_major, int) and ios_major > 0:
|
||||
fields["ios_major"] = ios_major
|
||||
return fields
|
||||
|
||||
|
||||
class MockLocationProvider:
|
||||
"""假定位提供者:接口与 :class:`core.location_provider.RealLocationProvider` 完全一致。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Any = None,
|
||||
on_log: Optional[Callable[[str, str], None]] = None,
|
||||
latency: Optional[tuple] = None,
|
||||
log: Optional[Callable[[str, str], None]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
:param device: 可选,``DeviceManager`` 返回的 Device dict(仅用于取 udid/版本信息)。
|
||||
:param on_log: 可选日志回调 ``(level, msg)``,供服务端把日志推给前端。
|
||||
:param latency: 可选 ``(min, max)`` 秒;默认 5~10ms。
|
||||
:param log: ``on_log`` 的别名(``core/server.py`` 用 ``MockLocationProvider(log=self.log)``)。
|
||||
"""
|
||||
self._device = device
|
||||
self._on_log = log or on_log
|
||||
self._latency = latency or (MOCK_LATENCY_MIN, MOCK_LATENCY_MAX)
|
||||
self._connected = False
|
||||
self._info: dict = {}
|
||||
self._last_location: Optional[tuple] = None
|
||||
self._send_count = 0
|
||||
self._last_log_at = 0.0
|
||||
self._started_at = time.time()
|
||||
|
||||
# ------------------------------------------------------------------ 日志
|
||||
def _log(self, level: str, msg: str) -> None:
|
||||
text = f"[模拟模式] {msg}"
|
||||
getattr(logger, level if level in ("debug", "info", "warning", "error") else "info", logger.info)(text)
|
||||
if self._on_log is not None:
|
||||
try:
|
||||
self._on_log(level, text)
|
||||
except Exception: # 日志失败绝不能影响定位流程
|
||||
logger.exception("on_log 回调异常(已忽略)")
|
||||
|
||||
async def _sleep_roundtrip(self) -> None:
|
||||
"""模拟真机 USB/DVT 往返耗时 5~10ms。"""
|
||||
low, high = self._latency
|
||||
delay = random.uniform(float(low), float(high))
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# -------------------------------------------------------------- 契约接口
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""是否已「连接」(mock 下 connect 成功后恒为 True)。"""
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def last_location(self) -> Optional[tuple]:
|
||||
"""最近一次下发的 ``(lat, lon)``,未下发过为 ``None``。"""
|
||||
return self._last_location
|
||||
|
||||
@property
|
||||
def send_count(self) -> int:
|
||||
"""累计下发次数(便于自测断言)。"""
|
||||
return self._send_count
|
||||
|
||||
async def connect(self, device: Any = None) -> dict:
|
||||
"""「连接」假设备,永远成功,不抛异常。"""
|
||||
if device is not None:
|
||||
self._device = device
|
||||
self._info = _detect_mock_device_fields(self._device)
|
||||
await self._sleep_roundtrip()
|
||||
self._connected = True
|
||||
self._log("success", f"已连接{self._info['name']}(udid={self._info['udid']},iOS {self._info['ios_version']})")
|
||||
self._log("info", "模拟模式:定位下发仅记录日志,不会真正修改设备位置")
|
||||
return {
|
||||
"ok": True,
|
||||
"mock": True,
|
||||
"udid": self._info["udid"],
|
||||
"name": self._info["name"],
|
||||
"ios_version": self._info["ios_version"],
|
||||
"ios_major": self._info["ios_major"],
|
||||
"connection": "mock",
|
||||
"tunnel": "connected",
|
||||
"detail": f"模拟设备已连接(iOS {self._info['ios_version']},无需隧道)",
|
||||
}
|
||||
|
||||
async def set_location(self, lat: float, lon: float) -> None:
|
||||
"""记录一次单点定位;永远成功。"""
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
await self._sleep_roundtrip()
|
||||
if not self._connected:
|
||||
# mock 模式刻意不报错:自动补一次 connect,保证端到端流程永远走得通
|
||||
self._log("warn", "尚未 connect 就收到 set_location,已自动补建模拟会话")
|
||||
await self.connect(self._device)
|
||||
self._last_location = (lat, lon)
|
||||
self._send_count += 1
|
||||
# 播放时按 5Hz 下发,逐次刷日志会淹没界面(服务端播放引擎已有"每 5 秒聚合"日志),
|
||||
# 这里同样做节流:每 25 次或每 5 秒才记一条。
|
||||
_now = time.time()
|
||||
if (
|
||||
self._send_count <= 3
|
||||
or self._send_count % 25 == 0
|
||||
or (_now - self._last_log_at) >= 5.0
|
||||
):
|
||||
self._last_log_at = _now
|
||||
self._log("info", f"下发定位 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次,模拟)")
|
||||
|
||||
async def clear_location(self) -> None:
|
||||
"""「恢复真实定位」;永远成功。"""
|
||||
await self._sleep_roundtrip()
|
||||
if self._last_location is not None:
|
||||
lat, lon = self._last_location
|
||||
self._log("info", f"清除模拟定位,设备恢复真实定位(上次 lat={lat:.6f}, lon={lon:.6f},模拟)")
|
||||
else:
|
||||
self._log("info", "清除模拟定位,设备恢复真实定位(此前未下发过,模拟)")
|
||||
self._last_location = None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭会话;永远成功、幂等。"""
|
||||
if not self._connected:
|
||||
return
|
||||
await self._sleep_roundtrip()
|
||||
self._connected = False
|
||||
self._log("info", "模拟定位会话已关闭")
|
||||
|
||||
def connect_sync(self, runner: Any, device: Any = None) -> dict:
|
||||
"""同步建立会话(供同步代码提交给 ``AsyncRunner``,绝不新建事件循环)。
|
||||
|
||||
``core/server.py`` 的 mock 分支就是 ``provider.connect_sync(self.runner)``。
|
||||
"""
|
||||
if self._connected:
|
||||
return dict(self._info)
|
||||
run = getattr(runner, "run", None)
|
||||
if not callable(run):
|
||||
raise RuntimeError("传入的 runner 不支持 run(coro),无法同步建立模拟会话")
|
||||
return run(self.connect(device))
|
||||
|
||||
async def probe(self) -> dict:
|
||||
"""「测试连接」按钮用:mock 下恒为可用。"""
|
||||
major = int(self._info.get("ios_major") or MOCK_IOS_MAJOR)
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": f"模拟模式:DVT 虚拟定位可用(iOS {major},无需隧道)",
|
||||
"ios_major": major,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 辅助
|
||||
def __repr__(self) -> str: # pragma: no cover - 调试用
|
||||
return (
|
||||
f"<MockLocationProvider connected={self._connected} "
|
||||
f"udid={self._info.get('udid')!r} sends={self._send_count}>"
|
||||
)
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""运行统计(供 /api/health 或调试使用)。"""
|
||||
return {
|
||||
"mock": True,
|
||||
"connected": self._connected,
|
||||
"send_count": self._send_count,
|
||||
"last_location": self._last_location,
|
||||
"started_at": getattr(self, "_started_at", None) or time.time(),
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
"""真机虚拟定位后端:pymobiledevice3 DVT / 老版 simulatelocation。
|
||||
|
||||
冻结契约(docs/PROTOCOL.md 5 节、task-2):
|
||||
|
||||
class RealLocationProvider:
|
||||
def __init__(self, rsd_host=None, rsd_port=None, udid=None) # 可加可选关键字参数
|
||||
async def connect(self, device) -> dict
|
||||
async def set_location(self, lat, lon) -> None
|
||||
async def clear_location(self) -> None
|
||||
async def close(self) -> None
|
||||
@property
|
||||
def connected(self) -> bool
|
||||
async def probe(self) -> dict # {"ok":bool,"detail":中文,"ios_major":int}
|
||||
|
||||
所有 API 调用均取自 pymobiledevice3 **真实源码**(版本与出处见 docs/DEVICE_NOTES.md):
|
||||
|
||||
* ``pymobiledevice3/services/dvt/instruments/dvt_provider.py:8`` ``class DvtProvider``
|
||||
* ``pymobiledevice3/services/dvt/instruments/location_simulation.py:17`` ``class LocationSimulation``
|
||||
(``set(latitude, longitude)`` / ``clear()``,async context manager)
|
||||
* ``pymobiledevice3/dtx_service_provider.py:80`` ``DvtProvider.service_name_for()`` 自动在
|
||||
RSD(``com.apple.instruments.dtservicehub``,iOS17+)与 lockdown
|
||||
(``com.apple.instruments.remoteserver.DVTSecureSocketProxy``,iOS<17)之间选择。
|
||||
* ``pymobiledevice3/remote/remote_service_discovery.py:103`` ``RemoteServiceDiscoveryService(address)``
|
||||
* ``pymobiledevice3/services/simulate_location.py:8`` ``DtSimulateLocation``(iOS<17 老服务
|
||||
``com.apple.dt.simulatelocation``,作为 DVT 失败时的兜底)
|
||||
|
||||
设计要点:
|
||||
* **长连接**:``connect()`` 建立一次 DVT 会话,之后 ``set_location`` 复用同一个 DTX channel,
|
||||
绝不每次重连(DVT 定位在连接关闭后即失效,见 DEVICE_NOTES.md 的 CLI 取证)。
|
||||
* **同一事件循环**:全部为原生 asyncio,不开线程、不新建事件循环。
|
||||
* **友好降级**:pymobiledevice3 缺失 / 隧道未建立 / 无设备 / 未开开发者模式,都抛带中文说明的
|
||||
:class:`LocationProviderError`,不会暴露 AttributeError 之类的内部错误。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
__all__ = [
|
||||
"RealLocationProvider",
|
||||
"LocationProviderError",
|
||||
"pymobiledevice3_available",
|
||||
"PMD3_MISSING_HINT",
|
||||
]
|
||||
|
||||
logger = logging.getLogger("iosloc.location.real")
|
||||
|
||||
#: 未安装 pymobiledevice3 时的中文修复建议(含本机实测可用的阿里云镜像)
|
||||
PMD3_MISSING_HINT = (
|
||||
"未检测到 pymobiledevice3 库。请在项目目录执行:"
|
||||
"./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
|
||||
)
|
||||
|
||||
#: iOS 17 是「必须走 RSD 隧道」的分界线
|
||||
RSD_REQUIRED_IOS_MAJOR = 17
|
||||
|
||||
#: 根目录 README/run.sh 的隧道提示(PROTOCOL.md 的 tunnel_hint 语义)
|
||||
TUNNEL_HINT = (
|
||||
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
|
||||
"请以管理员权限运行 run.sh(sudo ./run.sh);"
|
||||
"macOS 上也可用免 root 的隧道:"
|
||||
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
|
||||
)
|
||||
|
||||
|
||||
class LocationProviderError(RuntimeError):
|
||||
"""定位后端异常,``str(e)`` 一律是可直接展示给用户的中文信息。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pymobiledevice3 懒加载:未安装时 import core.location_provider 仍然成功
|
||||
# ---------------------------------------------------------------------------
|
||||
_pmd3_cache: Optional[dict] = None
|
||||
_pmd3_error: Optional[str] = None
|
||||
|
||||
|
||||
def _import_pymobiledevice3() -> dict:
|
||||
"""惰性导入 pymobiledevice3(成功结果缓存;失败抛 :class:`LocationProviderError`)。"""
|
||||
global _pmd3_cache, _pmd3_error
|
||||
if _pmd3_cache is not None:
|
||||
return _pmd3_cache
|
||||
try:
|
||||
from pymobiledevice3.lockdown import create_using_usbmux
|
||||
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
|
||||
from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
|
||||
from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation
|
||||
from pymobiledevice3.services.simulate_location import DtSimulateLocation
|
||||
from pymobiledevice3 import usbmux as usbmux_module
|
||||
except Exception as exc: # ImportError / 依赖缺失 / 版本不兼容
|
||||
_pmd3_error = f"{type(exc).__name__}: {exc}"
|
||||
logger.error("导入 pymobiledevice3 失败:%s", _pmd3_error)
|
||||
raise LocationProviderError(f"{PMD3_MISSING_HINT}(详细错误:{_pmd3_error})") from exc
|
||||
_pmd3_cache = {
|
||||
"create_using_usbmux": create_using_usbmux,
|
||||
"RemoteServiceDiscoveryService": RemoteServiceDiscoveryService,
|
||||
"DvtProvider": DvtProvider,
|
||||
"LocationSimulation": LocationSimulation,
|
||||
"DtSimulateLocation": DtSimulateLocation,
|
||||
"usbmux": usbmux_module,
|
||||
}
|
||||
return _pmd3_cache
|
||||
|
||||
|
||||
def pymobiledevice3_available() -> bool:
|
||||
"""pymobiledevice3 是否可用(不抛异常,供 /api/health、UI 提示使用)。"""
|
||||
try:
|
||||
_import_pymobiledevice3()
|
||||
except LocationProviderError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def pymobiledevice3_version() -> Optional[str]:
|
||||
"""返回已安装的 pymobiledevice3 版本号,取不到返回 ``None``。"""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("pymobiledevice3")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 小工具
|
||||
# ---------------------------------------------------------------------------
|
||||
def _parse_ios_major(value: Any) -> int:
|
||||
"""把 ``"26.0"`` / ``26`` / ``"17.4.1"`` 解析成主版本号;失败返回 0。"""
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
head = text.split(".", 1)[0]
|
||||
try:
|
||||
return int(head)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _device_field(device: Any, key: str, default: Any = None) -> Any:
|
||||
if device is None:
|
||||
return default
|
||||
if isinstance(device, dict):
|
||||
value = device.get(key, default)
|
||||
else:
|
||||
value = getattr(device, key, default)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _wrap(action: str, exc: BaseException) -> LocationProviderError:
|
||||
"""把底层异常翻译成中文可读信息。"""
|
||||
if isinstance(exc, LocationProviderError):
|
||||
return exc
|
||||
name = type(exc).__name__
|
||||
text = str(exc) or name
|
||||
low = text.lower()
|
||||
|
||||
# StartServiceError 的 __init__ 不把信息传给基类(exceptions.py:125-129),str() 为空
|
||||
if name == "StartServiceError":
|
||||
service_name = getattr(exc, "service_name", "?")
|
||||
message = getattr(exc, "message", "") or ""
|
||||
text = f"StartServiceError({service_name}): {message}"
|
||||
low = text.lower()
|
||||
|
||||
if isinstance(exc, (ConnectionRefusedError,)) or "connection refused" in low or "errno 61" in low:
|
||||
detail = "无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立"
|
||||
elif isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or "timed out" in low:
|
||||
detail = "操作超时,设备可能未响应或隧道已断开"
|
||||
elif "developerdiskimage" in low or "developer disk image" in low or "mounter mount" in low:
|
||||
detail = (
|
||||
"设备未挂载开发者镜像(DeveloperDiskImage),DVT 服务不可用。"
|
||||
"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount"
|
||||
)
|
||||
elif "developer mode" in low or "developermode" in low:
|
||||
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
|
||||
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
|
||||
detail = (
|
||||
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
|
||||
"./.venv/bin/python -m pymobiledevice3 mounter auto-mount;"
|
||||
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
|
||||
)
|
||||
elif "not paired" in low or "notpaired" in low or "pair" in low and "invalid" in low:
|
||||
detail = "设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」,或先执行 pymobiledevice3 lockdown pair"
|
||||
elif "no device" in low or "nodeviceconnected" in low:
|
||||
detail = "未发现 iOS 设备:请用 USB 连接 iPhone,解锁并在手机上点击「信任」"
|
||||
else:
|
||||
detail = f"底层错误:{name}: {text}"
|
||||
return LocationProviderError(f"{action}失败:{detail}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真机 Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
class RealLocationProvider:
|
||||
"""真机定位提供者。兼容 iOS<17(lockdown 直连 DVT)与 iOS≥17(RSD 隧道 + DVT)。
|
||||
|
||||
策略(``self.strategy``):
|
||||
|
||||
``dvt-rsd``
|
||||
iOS ≥ 17 且提供了 RSD 地址(或注入了已连接的 RSD 对象):
|
||||
``RemoteServiceDiscoveryService((host, port))`` → ``DvtProvider(rsd)`` →
|
||||
``LocationSimulation``。
|
||||
``dvt-lockdown``
|
||||
没有 RSD 地址时走 usbmux/lockdown:``create_using_usbmux(serial=udid)`` →
|
||||
``DvtProvider(lockdown)`` → ``LocationSimulation``(适用于 iOS<17)。
|
||||
``legacy``
|
||||
``dvt-lockdown`` 失败时的兜底:``DtSimulateLocation``(老服务
|
||||
``com.apple.dt.simulatelocation``,仅 iOS<17)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rsd_host: Optional[str] = None,
|
||||
rsd_port: Optional[int] = None,
|
||||
udid: Optional[str] = None,
|
||||
*,
|
||||
rsd: Any = None,
|
||||
lockdown: Any = None,
|
||||
connect_timeout: float = 15.0,
|
||||
on_log: Optional[Callable[[str, str], None]] = None,
|
||||
log: Optional[Callable[[str, str], None]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
:param rsd_host: RSD 隧道主机(由 lead 的 core/tunnel.py 提供),无隧道时为 ``None``。
|
||||
:param rsd_port: RSD 隧道端口。
|
||||
:param udid: 目标设备 UDID;``None`` 表示用第一台 USB 设备。
|
||||
:param rsd: **可选扩展**:已连接好的 ``RemoteServiceDiscoveryService`` 对象。用于把免 root 的
|
||||
进程内 userspace 隧道(``UserspaceRsdTunnel``)直接注入进来;给了之后就忽略 host/port。
|
||||
:param lockdown: **可选扩展**:已连接好的 ``LockdownClient``,避免重复建连。
|
||||
:param connect_timeout: 建立会话的超时秒数。
|
||||
:param on_log: 日志回调 ``(level, msg)``,供服务端推给前端。
|
||||
:param log: ``on_log`` 的别名(``core/server.py`` 用 ``RealLocationProvider(..., log=self.log)``)。
|
||||
"""
|
||||
self._rsd_host = rsd_host
|
||||
self._rsd_port = int(rsd_port) if rsd_port else None
|
||||
self._udid = udid
|
||||
self._injected_rsd = rsd
|
||||
self._injected_lockdown = lockdown
|
||||
self._connect_timeout = float(connect_timeout)
|
||||
self._on_log = log or on_log
|
||||
|
||||
self._connected = False
|
||||
self._strategy: Optional[str] = None
|
||||
self._session_info: dict = {}
|
||||
self._last_location: Optional[tuple] = None
|
||||
self._send_count = 0
|
||||
self._connected_at: Optional[float] = None
|
||||
|
||||
# pymobiledevice3 对象
|
||||
self._rsd_obj: Any = None
|
||||
self._lockdown_obj: Any = None
|
||||
self._dvt: Any = None
|
||||
self._location: Any = None
|
||||
self._legacy: Any = None
|
||||
|
||||
# 连接过程中从设备侧探测到的真实信息
|
||||
self._detected_version: Optional[str] = None
|
||||
self._detected_udid: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------ 日志
|
||||
def _log(self, level: str, msg: str) -> None:
|
||||
text = f"[真机] {msg}"
|
||||
log_fn = getattr(logger, level if level in ("debug", "info", "warning", "error") else "info", logger.info)
|
||||
log_fn(text)
|
||||
if self._on_log is not None:
|
||||
try:
|
||||
self._on_log(level, text)
|
||||
except Exception:
|
||||
logger.exception("on_log 回调异常(已忽略)")
|
||||
|
||||
# -------------------------------------------------------------- 只读属性
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""会话是否已建立(DVT channel 已就绪)。"""
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def strategy(self) -> Optional[str]:
|
||||
"""当前使用的策略:``dvt-rsd`` / ``dvt-lockdown`` / ``legacy``。"""
|
||||
return self._strategy
|
||||
|
||||
@property
|
||||
def rsd_address(self) -> Optional[tuple]:
|
||||
"""当前 RSD 地址 ``(host, port)``,没有则为 ``None``。"""
|
||||
if self._rsd_host and self._rsd_port:
|
||||
return (self._rsd_host, self._rsd_port)
|
||||
if self._rsd_obj is not None:
|
||||
address = getattr(getattr(self._rsd_obj, "service", None), "address", None)
|
||||
if address:
|
||||
return (address[0], int(address[1]))
|
||||
return None
|
||||
|
||||
@property
|
||||
def last_location(self) -> Optional[tuple]:
|
||||
"""最近一次成功下发的 ``(lat, lon)``。"""
|
||||
return self._last_location
|
||||
|
||||
@property
|
||||
def send_count(self) -> int:
|
||||
"""累计下发次数。"""
|
||||
return self._send_count
|
||||
|
||||
# ------------------------------------------------------------------ 连接
|
||||
async def connect(self, device: Any = None) -> dict:
|
||||
"""建立(长连接)DVT 会话,返回中文可读的会话信息 dict。
|
||||
|
||||
:raises LocationProviderError: 任何失败都用中文说明原因与修复办法。
|
||||
"""
|
||||
if self._connected:
|
||||
return dict(self._session_info)
|
||||
|
||||
udid = _device_field(device, "udid", None) or self._udid
|
||||
if udid:
|
||||
self._udid = str(udid)
|
||||
ios_version = str(_device_field(device, "ios_version", "") or "")
|
||||
ios_major = _parse_ios_major(_device_field(device, "ios_major", None) or ios_version)
|
||||
developer_mode = _device_field(device, "developer_mode", None)
|
||||
state = _device_field(device, "state", None)
|
||||
self._connection = str(_device_field(device, "connection", "usb") or "usb")
|
||||
|
||||
if state in ("unpaired",):
|
||||
raise LocationProviderError(
|
||||
"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
|
||||
"./.venv/bin/python -m pymobiledevice3 lockdown pair)"
|
||||
)
|
||||
if state in ("unavailable",):
|
||||
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
|
||||
if developer_mode is False:
|
||||
raise LocationProviderError(
|
||||
"设备未开启「开发者模式」:请在 iPhone 上打开 设置 → 隐私与安全性 → 开发者模式,"
|
||||
"重启设备并确认开启后重试(iOS 16 及以上必须开启)"
|
||||
)
|
||||
|
||||
pmd3 = _import_pymobiledevice3()
|
||||
has_rsd = self._injected_rsd is not None or bool(self._rsd_host and self._rsd_port)
|
||||
|
||||
if has_rsd:
|
||||
await self._connect_dvt_rsd(pmd3)
|
||||
else:
|
||||
await self._connect_lockdown_path(pmd3, ios_major, ios_version)
|
||||
|
||||
# 隧道/设备侧查到的真实版本优先(连接前拿到的版本可能为空或过期)
|
||||
if self._detected_version:
|
||||
ios_version = self._detected_version
|
||||
ios_major = _parse_ios_major(self._detected_version) or ios_major
|
||||
if self._detected_udid and not self._udid:
|
||||
self._udid = self._detected_udid
|
||||
|
||||
self._connected = True
|
||||
self._connected_at = time.time()
|
||||
self._session_info = {
|
||||
"ok": True,
|
||||
"mock": False,
|
||||
"udid": self._udid,
|
||||
"ios_version": ios_version,
|
||||
"ios_major": ios_major,
|
||||
"connection": self._connection,
|
||||
"tunnel": "connected" if self._strategy == "dvt-rsd" else "idle",
|
||||
"strategy": self._strategy,
|
||||
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
|
||||
"pymobiledevice3": pymobiledevice3_version(),
|
||||
"detail": self._describe_ready(),
|
||||
}
|
||||
self._log("success", self._session_info["detail"])
|
||||
return dict(self._session_info)
|
||||
|
||||
def connect_sync(self, runner: Any, device: Any = None) -> dict:
|
||||
"""同步建立会话(把协程提交给调用方的 runner,绝不新建事件循环/线程)。
|
||||
|
||||
与 :class:`MockLocationProvider.connect_sync` 行为一致,便于 lead 的同步装配代码统一调用。
|
||||
"""
|
||||
if self._connected:
|
||||
return dict(self._session_info)
|
||||
run = getattr(runner, "run", None)
|
||||
if not callable(run):
|
||||
raise LocationProviderError("传入的 runner 不支持 run(coro),无法同步建立设备会话")
|
||||
return run(self.connect(device))
|
||||
|
||||
def _describe_ready(self) -> str:
|
||||
if self._strategy == "dvt-rsd":
|
||||
address = self.rsd_address
|
||||
where = f"RSD {address[0]}:{address[1]}" if address else "RSD 隧道"
|
||||
return f"DVT 虚拟定位会话已就绪(iOS 17+ / {where},长连接保持中)"
|
||||
if self._strategy == "dvt-lockdown":
|
||||
return "DVT 虚拟定位会话已就绪(iOS<17 / usbmux+lockdown,长连接保持中)"
|
||||
return "虚拟定位会话已就绪(老版 com.apple.dt.simulatelocation 服务)"
|
||||
|
||||
async def _connect_dvt_rsd(self, pmd3: dict) -> None:
|
||||
"""iOS≥17:RSD 隧道 + DVT(长连接)。"""
|
||||
rsd = self._injected_rsd
|
||||
try:
|
||||
if rsd is None:
|
||||
rsd = pmd3["RemoteServiceDiscoveryService"]((self._rsd_host, self._rsd_port))
|
||||
self._log("info", f"正在连接 RSD 隧道 {self._rsd_host}:{self._rsd_port} …")
|
||||
await asyncio.wait_for(rsd.connect(), timeout=self._connect_timeout)
|
||||
self._rsd_obj = rsd
|
||||
# 隧道连上后可以校正设备信息(peer_info 里带 OSVersion / UniqueDeviceID)
|
||||
real_version = str(getattr(rsd, "product_version", "") or "")
|
||||
if real_version:
|
||||
self._detected_version = real_version
|
||||
real_udid = getattr(rsd, "udid", None)
|
||||
if real_udid:
|
||||
self._detected_udid = str(real_udid)
|
||||
self._log("info", "RSD 隧道已建立,正在打开 DVT(dtservicehub)通道 …")
|
||||
self._dvt = pmd3["DvtProvider"](rsd)
|
||||
await asyncio.wait_for(self._dvt.connect(), timeout=self._connect_timeout)
|
||||
self._location = pmd3["LocationSimulation"](self._dvt)
|
||||
await asyncio.wait_for(self._location.connect(), timeout=self._connect_timeout)
|
||||
self._strategy = "dvt-rsd"
|
||||
except LocationProviderError:
|
||||
await self._safe_close_rsd()
|
||||
raise
|
||||
except BaseException as exc:
|
||||
await self._safe_close_rsd()
|
||||
raise _wrap("建立 RSD/DVT 会话", exc) from exc
|
||||
|
||||
async def _connect_lockdown_path(self, pmd3: dict, ios_major: int, ios_version: str) -> None:
|
||||
"""无 RSD 地址:iOS<17 走 lockdown 直连 DVT,失败再退化到老服务。"""
|
||||
if ios_major >= RSD_REQUIRED_IOS_MAJOR:
|
||||
# iOS 17+ 没有隧道时 lockdown 上的 DVTSecureSocketProxy 已不存在,直接给中文修复建议
|
||||
raise LocationProviderError(
|
||||
f"设备为 iOS {ios_version or ios_major},需要先建立 RSD 隧道才能虚拟定位。" + TUNNEL_HINT
|
||||
)
|
||||
|
||||
lockdown = self._lockdown_obj or self._injected_lockdown
|
||||
if lockdown is None:
|
||||
try:
|
||||
self._log("info", f"正在通过 usbmux/lockdown 连接设备 {self._udid or '(第一台 USB 设备)'} …")
|
||||
lockdown = await asyncio.wait_for(
|
||||
pmd3["create_using_usbmux"](serial=self._udid), timeout=self._connect_timeout
|
||||
)
|
||||
except BaseException as exc:
|
||||
raise _wrap("通过 usbmux 连接设备", exc) from exc
|
||||
self._lockdown_obj = lockdown
|
||||
elif self._injected_lockdown is not None:
|
||||
self._lockdown_obj = self._injected_lockdown
|
||||
|
||||
# 用设备侧真实版本校正(连接前拿到的 ios_major 可能为空或过期)
|
||||
real_version = str(getattr(lockdown, "product_version", "") or "")
|
||||
if real_version:
|
||||
self._detected_version = real_version
|
||||
real_udid = getattr(lockdown, "udid", None)
|
||||
if real_udid:
|
||||
self._detected_udid = str(real_udid)
|
||||
real_major = _parse_ios_major(real_version) or ios_major
|
||||
if real_major >= RSD_REQUIRED_IOS_MAJOR:
|
||||
error = LocationProviderError(
|
||||
f"设备为 iOS {real_version or real_major},需要先建立 RSD 隧道才能虚拟定位。" + TUNNEL_HINT
|
||||
)
|
||||
# 这个 lockdown 连接是我们自己建的,报错前先释放,避免泄漏
|
||||
if self._injected_lockdown is None and self._lockdown_obj is not None:
|
||||
try:
|
||||
await self._lockdown_obj.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._lockdown_obj = None
|
||||
raise error
|
||||
|
||||
# 首选取证过的 DVT 路径(长连接)
|
||||
try:
|
||||
self._dvt = pmd3["DvtProvider"](lockdown)
|
||||
await asyncio.wait_for(self._dvt.connect(), timeout=self._connect_timeout)
|
||||
self._location = pmd3["LocationSimulation"](self._dvt)
|
||||
await asyncio.wait_for(self._location.connect(), timeout=self._connect_timeout)
|
||||
self._strategy = "dvt-lockdown"
|
||||
return
|
||||
except BaseException as exc:
|
||||
dvt_error = _wrap("建立 DVT 会话(iOS<17 直连)", exc)
|
||||
self._log("warning", f"{dvt_error};改用老版 simulatelocation 服务重试")
|
||||
|
||||
# 兜底:com.apple.dt.simulatelocation(pymobiledevice3 CLI 对 iOS<17 的官方路径)
|
||||
self._dvt = None
|
||||
self._location = None
|
||||
try:
|
||||
self._legacy = pmd3["DtSimulateLocation"](lockdown)
|
||||
service_name = str(getattr(self._legacy, "SERVICE_NAME", "com.apple.dt.simulatelocation"))
|
||||
# 关键:DtSimulateLocation.__init__ 不做任何设备 I/O(services/simulate_location.py:19-22),
|
||||
# 它每次 set/clear 才新开服务。这里必须真开一次服务做校验,否则会出现
|
||||
# 「connect 报成功、第一次下发才失败」的假成功(真机实测踩到过)。
|
||||
self._log("info", f"正在验证老版虚拟定位服务 {service_name} …")
|
||||
service = await asyncio.wait_for(
|
||||
lockdown.start_lockdown_developer_service(service_name), timeout=self._connect_timeout
|
||||
)
|
||||
try:
|
||||
await service.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._strategy = "legacy"
|
||||
self._log("info", f"已选用老版虚拟定位服务 {service_name}(服务可用性已实测)")
|
||||
except BaseException as exc:
|
||||
raise _wrap("建立老版虚拟定位会话(com.apple.dt.simulatelocation)", exc) from exc
|
||||
|
||||
async def _safe_close_rsd(self) -> None:
|
||||
for obj in (self._location, self._dvt):
|
||||
if obj is None:
|
||||
continue
|
||||
try:
|
||||
await obj.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._location = None
|
||||
self._dvt = None
|
||||
if self._rsd_obj is not None and self._injected_rsd is None:
|
||||
try:
|
||||
await self._rsd_obj.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._rsd_obj = None
|
||||
|
||||
# ------------------------------------------------------------------ 下发
|
||||
async def set_location(self, lat: float, lon: float) -> None:
|
||||
"""下发一次单点定位(复用长连接会话)。"""
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
|
||||
raise LocationProviderError(f"经纬度超出合法范围:lat={lat}, lon={lon}")
|
||||
if not self._connected:
|
||||
raise LocationProviderError("尚未连接设备,无法下发定位:请先调用 connect()(或点击界面上的「连接设备」)")
|
||||
|
||||
try:
|
||||
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
|
||||
await self._location.set(lat, lon)
|
||||
elif self._strategy == "legacy":
|
||||
await self._legacy.set(lat, lon)
|
||||
else:
|
||||
raise LocationProviderError("内部状态异常:没有可用的虚拟定位通道,请重新连接设备")
|
||||
except BaseException as exc:
|
||||
raise _wrap("下发定位", exc) from exc
|
||||
|
||||
self._last_location = (lat, lon)
|
||||
self._send_count += 1
|
||||
if self._send_count == 1 or self._send_count % 25 == 0:
|
||||
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
|
||||
|
||||
async def clear_location(self) -> None:
|
||||
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
|
||||
if not self._connected:
|
||||
self._log("debug", "当前未连接设备,clear_location 忽略")
|
||||
return
|
||||
try:
|
||||
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
|
||||
await self._location.clear()
|
||||
elif self._strategy == "legacy":
|
||||
await self._legacy.clear()
|
||||
except BaseException as exc:
|
||||
raise _wrap("清除虚拟定位", exc) from exc
|
||||
self._last_location = None
|
||||
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放会话:先 clear(恢复真实定位)再关闭 DVT / RSD / lockdown。幂等。"""
|
||||
if not self._connected and self._dvt is None and self._rsd_obj is None and self._lockdown_obj is None:
|
||||
return
|
||||
try:
|
||||
if self._connected:
|
||||
await self.clear_location()
|
||||
except Exception as exc:
|
||||
self._log("warning", f"关闭前清除虚拟定位失败(继续关闭会话):{exc}")
|
||||
|
||||
for obj in (self._location, self._dvt):
|
||||
if obj is None:
|
||||
continue
|
||||
try:
|
||||
await obj.close()
|
||||
except Exception as exc:
|
||||
self._log("debug", f"关闭 {type(obj).__name__} 时出错(忽略):{exc}")
|
||||
|
||||
if self._rsd_obj is not None and self._injected_rsd is None:
|
||||
try:
|
||||
await self._rsd_obj.close()
|
||||
except Exception as exc:
|
||||
self._log("debug", f"关闭 RSD 时出错(忽略):{exc}")
|
||||
if self._lockdown_obj is not None and self._injected_lockdown is None:
|
||||
try:
|
||||
await self._lockdown_obj.close()
|
||||
except Exception as exc:
|
||||
self._log("debug", f"关闭 lockdown 时出错(忽略):{exc}")
|
||||
|
||||
self._location = None
|
||||
self._dvt = None
|
||||
self._legacy = None
|
||||
self._rsd_obj = None
|
||||
self._lockdown_obj = None
|
||||
self._connected = False
|
||||
self._strategy = None
|
||||
self._connected_at = None
|
||||
self._log("info", "虚拟定位会话已关闭")
|
||||
|
||||
# ------------------------------------------------------------------ 探测
|
||||
async def probe(self, device: Any = None) -> dict:
|
||||
"""「测试连接」按钮:探测设备/DVT 是否可用。**不抛异常**。
|
||||
|
||||
:param device: 可选,``DeviceManager`` 的 Device dict(与 ``connect()`` 同一个对象)。
|
||||
传入后会先做静态判断(未配对 / 未开开发者模式 / iOS 版本),能省掉一次设备往返。
|
||||
:returns: ``{"ok": bool, "detail": 中文说明, "ios_major": int}``(另附 strategy 等字段)
|
||||
"""
|
||||
ios_major = _parse_ios_major(self._session_info.get("ios_major"))
|
||||
if device is not None:
|
||||
state = _device_field(device, "state", None)
|
||||
developer_mode = _device_field(device, "developer_mode", None)
|
||||
device_major = _parse_ios_major(
|
||||
_device_field(device, "ios_major", None) or _device_field(device, "ios_version", "")
|
||||
)
|
||||
if device_major:
|
||||
ios_major = device_major
|
||||
if state == "unpaired":
|
||||
return {
|
||||
"ok": False,
|
||||
"ios_major": ios_major,
|
||||
"strategy": None,
|
||||
"detail": "设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试",
|
||||
}
|
||||
if developer_mode is False:
|
||||
return {
|
||||
"ok": False,
|
||||
"ios_major": ios_major,
|
||||
"strategy": None,
|
||||
"detail": "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启,需重启设备)",
|
||||
}
|
||||
|
||||
try:
|
||||
pmd3 = _import_pymobiledevice3()
|
||||
except LocationProviderError as exc:
|
||||
return {"ok": False, "detail": str(exc), "ios_major": ios_major, "strategy": None}
|
||||
|
||||
if self._connected:
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": self._describe_ready(),
|
||||
"ios_major": ios_major,
|
||||
"strategy": self._strategy,
|
||||
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
|
||||
}
|
||||
|
||||
# 未连接:用一个临时会话探测,探测完立即释放(不会残留模拟定位)
|
||||
has_tunnel = self._injected_rsd is not None or bool(self._rsd_host and self._rsd_port)
|
||||
if not has_tunnel and (ios_major == 0 or ios_major >= RSD_REQUIRED_IOS_MAJOR):
|
||||
# 没有隧道、且是 iOS 17+(或版本未知):只有隧道状态可报,不做无意义的重连
|
||||
detail = (
|
||||
f"尚未配置 RSD 隧道(ios_major={ios_major or '未知'})。" + TUNNEL_HINT
|
||||
if ios_major >= RSD_REQUIRED_IOS_MAJOR or ios_major == 0
|
||||
else "尚未建立会话;iOS<17 可直接用 USB/lockdown 建立 DVT 会话,请先点击「连接设备」"
|
||||
)
|
||||
return {"ok": False, "detail": detail, "ios_major": ios_major, "strategy": None}
|
||||
|
||||
# iOS<17 且无隧道时也做真实探测(点「测试连接」就应该真的去连一次)
|
||||
probe = RealLocationProvider(
|
||||
rsd_host=self._rsd_host,
|
||||
rsd_port=self._rsd_port,
|
||||
udid=self._udid,
|
||||
rsd=self._injected_rsd,
|
||||
lockdown=self._injected_lockdown,
|
||||
connect_timeout=self._connect_timeout,
|
||||
)
|
||||
try:
|
||||
info = await probe.connect(device)
|
||||
except LocationProviderError as exc:
|
||||
return {"ok": False, "detail": str(exc), "ios_major": ios_major, "strategy": None}
|
||||
except BaseException as exc:
|
||||
return {"ok": False, "detail": _wrap("探测设备", exc).args[0], "ios_major": ios_major, "strategy": None}
|
||||
try:
|
||||
await probe.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": f"连接测试成功:{info.get('detail')}",
|
||||
"ios_major": _parse_ios_major(info.get("ios_major")) or ios_major,
|
||||
"strategy": info.get("strategy"),
|
||||
"rsd": info.get("rsd"),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 辅助
|
||||
def stats(self) -> dict:
|
||||
"""运行统计(供日志/健康检查使用)。"""
|
||||
return {
|
||||
"mock": False,
|
||||
"connected": self._connected,
|
||||
"strategy": self._strategy,
|
||||
"udid": self._udid,
|
||||
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
|
||||
"send_count": self._send_count,
|
||||
"last_location": self._last_location,
|
||||
"connected_at": self._connected_at,
|
||||
}
|
||||
|
||||
def update_rsd(self, rsd_host: Optional[str], rsd_port: Optional[int]) -> None:
|
||||
"""隧道重建后更新 RSD 地址(需要重新 connect 才生效)。"""
|
||||
self._rsd_host = rsd_host
|
||||
self._rsd_port = int(rsd_port) if rsd_port else None
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - 调试用
|
||||
return (
|
||||
f"<RealLocationProvider connected={self._connected} strategy={self._strategy} "
|
||||
f"udid={self._udid!r} rsd={self.rsd_address}>"
|
||||
)
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
"""真实感定位模型:多尺度 GPS 误差 + 发送节奏抖动 + 运动学速度。
|
||||
|
||||
为什么不用 random():真实手机的 GPS 误差是**缓慢游走**的(受卫星几何、多径、电离层影响),
|
||||
相邻两次定位通常只差几十厘米到一两米,而且有惯性。如果每个点独立随机跳,轨迹会变成毛刺,
|
||||
速度和航向也会剧烈抖动——这类特征在验收测试里会掩盖真实问题(也有经验的风控一眼能看出)。
|
||||
|
||||
这里用**值噪声(value noise)+ 平滑插值 + 多尺度叠加**建模:
|
||||
- **多尺度误差**:真实 GPS 误差是幂律分布(Allan 方差),不是单一尺度。
|
||||
这里用 3 个八度叠加:慢漂(相关长度 ~120 米,多径/电离层)+ 中尺度(~30 米,卫星几何)
|
||||
+ 快抖(~6 米,接收机噪声)。单尺度噪声的统计特征一眼可辨。
|
||||
- **发送节奏抖动**:真实 GPS 回调不是精确 1.000 秒,而是 0.85~1.15 秒抖动,
|
||||
偶尔丢一次采样。节奏完全均匀是最容易被识别的特征之一。
|
||||
- **速度由位移/时间推算**:这与 iOS 从坐标序列推算 speed 的方式一致,
|
||||
因此"上报速度"与"实际位移"天然自洽,不会出现自相矛盾的数据。
|
||||
- 纯函数式、无状态、可复现:同样的距离轴/时间轴永远得到同样的噪声值,便于回归测试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
# 相关长度:多少米走完一个完整的"漂移周期"
|
||||
DEFAULT_CORRELATION_M = 30.0
|
||||
# 默认漂移半径(米):手机 GPS 在城市环境下的典型误差量级
|
||||
DEFAULT_DRIFT_M = 4.0
|
||||
DEFAULT_DRIFT_PERIOD_S = 18.0
|
||||
# 默认速度浮动幅度:±8%
|
||||
DEFAULT_SPEED_JITTER = 0.08
|
||||
|
||||
MAX_DRIFT_M = 50.0
|
||||
MAX_SPEED_JITTER = 0.6
|
||||
|
||||
# 多尺度误差的三个八度:(相对权重, 相关长度倍数)
|
||||
# 慢漂:中尺度:快抖 ≈ 0.5 : 0.33 : 0.17,接近 GPS 误差的幂律谱。
|
||||
# 快抖的相关长度不能太短:取 0.5×30m = 15m。若取 6~10m,1Hz 下每秒误差变化可达
|
||||
# 6~8 米,位移推算出的"瞬时速度"会明显超出步行/跑步范围(真实接收机内部对速度做过平滑)。
|
||||
ERROR_OCTAVES = ((0.5, 4.0), (0.34, 1.0), (0.16, 0.5))
|
||||
# 叠加后的幅值归一化系数:实测三个尺度加权叠加的 RMS 约为单尺度的 0.55 倍,
|
||||
# 除以该系数即可让 drift_m 参数与"典型偏移半径"一致(数值由测试标定)。
|
||||
OCTAVE_NORMALIZER = 1.0 / 0.55
|
||||
# 单个采样的偏移上限(相对 radius 的倍数):真实 GPS 误差有长尾,但不会无限大
|
||||
MAX_OFFSET_FACTOR = 1.8
|
||||
|
||||
# 发送间隔抖动:真实 1Hz GPS 的实际间隔分布
|
||||
DEFAULT_TIMING_JITTER = 0.15 # ±15%
|
||||
DROPOUT_PROBABILITY = 0.02 # 约 2% 的采样会丢一次(变成 2 秒间隔)
|
||||
MIN_INTERVAL_FACTOR = 0.3 # 抖动下限保护(避免退化成高频)
|
||||
|
||||
|
||||
def _hash01(seed: int, index: int) -> float:
|
||||
"""把 (seed, index) 映射成 [0,1) 的确定性伪随机数(无需 numpy)。"""
|
||||
x = (index * 374761393 + seed * 668265263) & 0xFFFFFFFF
|
||||
x = (x ^ (x >> 13)) * 1274126177 & 0xFFFFFFFF
|
||||
x = x ^ (x >> 16)
|
||||
return (x & 0xFFFFFF) / float(0x1000000)
|
||||
|
||||
|
||||
def _smooth(t: float) -> float:
|
||||
"""smoothstep:让分段插值在节点处一阶连续,避免折角。"""
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def value_noise(seed: int, x: float) -> float:
|
||||
"""一维值噪声,返回 [-1, 1],在 x 的整数节点间平滑插值。"""
|
||||
i0 = math.floor(x)
|
||||
frac = x - i0
|
||||
a = _hash01(seed, i0) * 2.0 - 1.0
|
||||
b = _hash01(seed, i0 + 1) * 2.0 - 1.0
|
||||
return a + (b - a) * _smooth(frac)
|
||||
|
||||
|
||||
def fbm(seed: int, x: float, octaves: int = 2) -> float:
|
||||
"""两个八度的分形噪声,归一化到 [-1, 1]。"""
|
||||
total = 0.0
|
||||
amplitude = 1.0
|
||||
frequency = 1.0
|
||||
norm = 0.0
|
||||
for octave in range(max(1, octaves)):
|
||||
total += value_noise(seed + octave * 7919, x * frequency) * amplitude
|
||||
norm += amplitude
|
||||
amplitude *= 0.5
|
||||
frequency *= 2.3
|
||||
return total / norm if norm else 0.0
|
||||
|
||||
|
||||
class DriftGenerator:
|
||||
"""按"沿轨迹行进的距离"生成平滑漂移偏移量与速度浮动系数。
|
||||
|
||||
- 位置漂移:沿行进方向与垂直方向各一份噪声,合成横向摆动(GPS 误差的常见形态);
|
||||
- 速度浮动:乘性系数,围绕 1.0 平滑波动,让瞬时速度在设定值上下浮动。
|
||||
"""
|
||||
|
||||
def __init__(self, seed: int = 20261014) -> None:
|
||||
self.seed = int(seed)
|
||||
|
||||
# ---------------- 位置漂移 ----------------
|
||||
def offset_m(self, distance_m: float, drift_m: float,
|
||||
correlation_m: float = DEFAULT_CORRELATION_M,
|
||||
octaves: bool = True) -> tuple[float, float]:
|
||||
"""返回 (沿行进方向偏移, 垂直方向偏移),单位米。
|
||||
|
||||
octaves=True 时叠加 3 个尺度的误差(慢漂 + 中尺度 + 快抖),
|
||||
更接近真实 GPS 误差的幂律谱;False 时退化为单一尺度(旧行为)。
|
||||
"""
|
||||
if drift_m <= 0:
|
||||
return 0.0, 0.0
|
||||
scale = max(5.0, correlation_m)
|
||||
if not octaves:
|
||||
x = distance_m / scale
|
||||
along = fbm(self.seed, x) * drift_m
|
||||
cross = fbm(self.seed + 104729, x + 0.5) * drift_m
|
||||
return along, cross
|
||||
along = 0.0
|
||||
cross = 0.0
|
||||
for index, (weight, factor) in enumerate(ERROR_OCTAVES):
|
||||
x = distance_m / (scale * factor)
|
||||
# 每个尺度用不同种子与相位偏移,避免各尺度同相叠加成规律波形
|
||||
along += value_noise(self.seed + index * 1013, x) * weight
|
||||
cross += value_noise(self.seed + 104729 + index * 7919, x + 0.37 * (index + 1)) * weight
|
||||
# 多尺度叠加后幅值会明显小于单尺度(各尺度并不总是同相),
|
||||
# 这里归一化,保证 drift_m 参数就是"典型偏移半径",与用户设定一致。
|
||||
along *= OCTAVE_NORMALIZER
|
||||
cross *= OCTAVE_NORMALIZER
|
||||
# 合成偏移做上限裁剪:真实 GPS 误差有长尾,但不会无限大,
|
||||
# 否则偶发的大偏移会被下游 App 当成"跳点"整段丢弃。
|
||||
magnitude = math.hypot(along, cross)
|
||||
if magnitude > MAX_OFFSET_FACTOR:
|
||||
shrink = MAX_OFFSET_FACTOR / magnitude
|
||||
along *= shrink
|
||||
cross *= shrink
|
||||
return along * drift_m, cross * drift_m
|
||||
|
||||
def offset_latlng(self, lat: float, lng: float, distance_m: float,
|
||||
heading_deg: float, drift_m: float,
|
||||
correlation_m: float = DEFAULT_CORRELATION_M) -> tuple[float, float]:
|
||||
"""把米制偏移换算成经纬度偏移(按当前纬度修正经度比例)。"""
|
||||
along, cross = self.offset_m(distance_m, drift_m, correlation_m)
|
||||
if along == 0.0 and cross == 0.0:
|
||||
return 0.0, 0.0
|
||||
rad = math.radians(heading_deg)
|
||||
# 行进方向单位向量(x=东, y=北)
|
||||
fx, fy = math.sin(rad), math.cos(rad)
|
||||
# 垂直方向(右手侧)
|
||||
px, py = fy, -fx
|
||||
east_m = along * fx + cross * px
|
||||
north_m = along * fy + cross * py
|
||||
dlat = north_m / 111320.0
|
||||
cos_lat = math.cos(math.radians(max(-85.0, min(85.0, lat))))
|
||||
dlng = east_m / (111320.0 * max(0.05, cos_lat))
|
||||
return dlat, dlng
|
||||
|
||||
# ---------------- 速度浮动 ----------------
|
||||
def speed_factor(self, elapsed_s: float, jitter: float,
|
||||
period_s: float = DEFAULT_DRIFT_PERIOD_S) -> float:
|
||||
"""返回速度乘性系数(1.0 表示不浮动)。
|
||||
|
||||
对应真实跑步中步频/步幅的自然波动与路况影响:
|
||||
用 2 个八度的低频噪声(不是正弦,因此没有可识别的周期)。
|
||||
"""
|
||||
if jitter <= 0:
|
||||
return 1.0
|
||||
x = elapsed_s / max(2.0, period_s)
|
||||
value = fbm(self.seed + 15485863, x + 3.25)
|
||||
factor = 1.0 + value * jitter
|
||||
# 防止极端情况下出现 0 或负速度
|
||||
return max(0.2, min(2.0, factor))
|
||||
|
||||
# ---------------- 发送节奏(真实 GPS 不是精确等间隔) ----------------
|
||||
def timing_factor(self, sample_index: int, jitter: float = DEFAULT_TIMING_JITTER,
|
||||
dropouts: bool = True) -> float:
|
||||
"""返回本次发送间隔的乘性系数。
|
||||
|
||||
真实 1Hz GPS 的实际间隔在 0.85~1.15 秒之间抖动,偶发丢采样时变成约 2 秒。
|
||||
间隔完全均匀(精确 1.000s)在数据上非常"人工",部分验收用例甚至会据此告警。
|
||||
"""
|
||||
if jitter <= 0:
|
||||
return 1.0
|
||||
# 用噪声而不是 random:连续几次采样的间隔会小幅相关,像真实接收机
|
||||
value = value_noise(self.seed + 777771, sample_index * 0.35)
|
||||
factor = 1.0 + value * jitter
|
||||
# 丢采样是独立随机事件(不能用平滑噪声表达),用哈希伪随机
|
||||
if dropouts and _hash01(self.seed + 31337, sample_index) < DROPOUT_PROBABILITY:
|
||||
factor += 1.0 # 丢一次采样 → 间隔约翻倍(真实 GPS 偶发情况)
|
||||
return max(MIN_INTERVAL_FACTOR, min(2.5, factor))
|
||||
|
||||
# ---------------- 粗差(真实 GPS 偶发的坏点) ----------------
|
||||
def gross_error_m(self, sample_index: int, probability: float = 0.0,
|
||||
magnitude_m: float = 30.0) -> tuple[float, float]:
|
||||
"""按概率生成一次"粗差"偏移(多径/城市峡谷导致的坏点)。
|
||||
|
||||
默认关闭(probability=0)。开启后可用来测你自己 App 的
|
||||
`horizontalAccuracy` 过滤与跳点剔除逻辑是否生效。
|
||||
"""
|
||||
if probability <= 0:
|
||||
return 0.0, 0.0
|
||||
roll = _hash01(self.seed + 5150, sample_index)
|
||||
if roll >= probability:
|
||||
return 0.0, 0.0
|
||||
# 坏点方向随机、幅度在 0.5~1.5 倍之间
|
||||
angle = _hash01(self.seed + 6161, sample_index) * 2.0 * math.pi
|
||||
scale = magnitude_m * (0.5 + _hash01(self.seed + 7171, sample_index))
|
||||
return math.cos(angle) * scale, math.sin(angle) * scale
|
||||
|
||||
|
||||
_generator = DriftGenerator()
|
||||
|
||||
|
||||
def default_generator() -> DriftGenerator:
|
||||
return _generator
|
||||
@@ -0,0 +1,687 @@
|
||||
"""轨迹播放引擎。
|
||||
|
||||
- 按速度剖面积分推进弧长(真实物理感受:转弯自动减速)。
|
||||
- 独立的 worker 线程:即使设备下发阻塞也不会卡住 HTTP/WS,且保证同一时刻只有一个
|
||||
set_location 在飞行中(顺序严格不乱)。
|
||||
- 倍速只影响推进速率,不影响下发频率(send_hz)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from . import geoutils, noise, protocol, route_engine
|
||||
|
||||
STATUS_INTERVAL = 0.5
|
||||
TICK = 0.05
|
||||
MAX_CATCHUP = 5.0
|
||||
|
||||
# 哨兵:用于区分"参数未提供"与"显式传 False/0"
|
||||
_UNSET: Any = object()
|
||||
|
||||
|
||||
class PlaybackEngine:
|
||||
def __init__(
|
||||
self,
|
||||
runner: Any,
|
||||
config: Any,
|
||||
on_location: Callable[[dict[str, Any]], None],
|
||||
on_status: Callable[[dict[str, Any]], None],
|
||||
on_log: Callable[[str, str], None] | None = None,
|
||||
) -> None:
|
||||
self.runner = runner
|
||||
self.config = config
|
||||
self.on_location = on_location
|
||||
self.on_status = on_status
|
||||
self.on_log = on_log
|
||||
|
||||
self._lock = threading.RLock()
|
||||
self._route: route_engine.Route | None = None
|
||||
self._provider: Any = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._wake = threading.Event()
|
||||
self._stop_flag = threading.Event()
|
||||
|
||||
self.playing = False
|
||||
self.paused = False
|
||||
self.loop = False
|
||||
self.speed_scale = 1.0
|
||||
# 播放代次:每次 start 自增,旧线程发现自己的代次过期就立即退出。
|
||||
# 这样即使 stop 时旧线程正卡在一次慢速下发里,也不会与新线程同时驱动设备。
|
||||
self._generation = 0
|
||||
self.distance = 0.0 # 已推进的弧长(米)
|
||||
self.tick_time = 0.0 # 播放用的虚拟时间(秒,已按倍速折算)
|
||||
self.started_at = 0.0
|
||||
self.last_status_at = 0.0
|
||||
self.last_sent_at = 0.0
|
||||
# 轨迹上的当前点(供 status 使用;上报点用 last_reported,两者不能混用)
|
||||
self.last_point: tuple[float, float] | None = None
|
||||
self.source = "play"
|
||||
self.sent_count = 0
|
||||
self.error: str | None = None
|
||||
# 状态序号:便于测试与前端判断"新旧",避免异步推送乱序带来的误判
|
||||
self.status_seq = 0
|
||||
# 是否已经 clear 过(避免 stop + shutdown 重复 clear)
|
||||
self._cleared = True
|
||||
# 最近一次在途的设备下发 future:clear 之前要等它结束,保证 set 与 clear 的顺序
|
||||
self._pending_dispatch: Any = None
|
||||
|
||||
# ---- 真实感模拟:GPS 漂移 + 速度浮动(都能开关,开关与幅度可随时改) ----
|
||||
self.drift_enabled = bool(config.get("drift_enabled", True))
|
||||
self.drift_m = float(config.get("drift_m") or noise.DEFAULT_DRIFT_M)
|
||||
self.speed_jitter_enabled = bool(config.get("speed_jitter_enabled", True))
|
||||
self.speed_jitter = float(config.get("speed_jitter") or noise.DEFAULT_SPEED_JITTER)
|
||||
self._drift = noise.default_generator()
|
||||
self.last_drift_m: float = 0.0
|
||||
# 发送节奏抖动(真实 GPS 不是精确等间隔)
|
||||
self.timing_jitter_enabled = bool(config.get("timing_jitter_enabled", True))
|
||||
# 上报速度由实际位移/时间推算(与 iOS 的算法一致)
|
||||
self.derive_speed = bool(config.get("derive_speed", True))
|
||||
# 粗差注入(默认关闭,用于测试自己 App 的过滤逻辑)
|
||||
self.gross_error_enabled = bool(config.get("gross_error_enabled", False))
|
||||
self.gross_error_probability = float(config.get("gross_error_probability") or 0.0)
|
||||
self.gross_error_m = float(config.get("gross_error_m") or 30.0)
|
||||
self.send_index = 0
|
||||
# 注意:self.last_point 是"轨迹上的点"(供 status 用),
|
||||
# 上报位置必须用独立变量,否则两者会被互相覆盖(曾因此导致播放线程崩溃)。
|
||||
self.last_reported: tuple[float, float] | None = None
|
||||
# 上一次"上报"的单调时刻:速度推算只依赖它,避免与其它时间戳变量互相干扰
|
||||
self.last_report_mono = 0.0
|
||||
# 最近一次上报的位移/时间间隔(供诊断与测试使用)
|
||||
self.last_speed_jump = 0.0
|
||||
self.last_speed_dt = 0.0
|
||||
|
||||
# ---------------- 属性 ----------------
|
||||
@property
|
||||
def route(self) -> route_engine.Route | None:
|
||||
with self._lock:
|
||||
return self._route
|
||||
|
||||
def set_provider(self, provider: Any) -> None:
|
||||
with self._lock:
|
||||
self._provider = provider
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
with self._lock:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
# ---------------- 真实感调参(漂移 / 速度浮动) ----------------
|
||||
def tuning(self) -> dict[str, Any]:
|
||||
return {
|
||||
"drift_enabled": self.drift_enabled,
|
||||
"drift_m": round(self.drift_m, 2),
|
||||
"speed_jitter_enabled": self.speed_jitter_enabled,
|
||||
"speed_jitter": round(self.speed_jitter, 4),
|
||||
"speed_jitter_percent": round(self.speed_jitter * 100, 1),
|
||||
"timing_jitter_enabled": self.timing_jitter_enabled,
|
||||
"derive_speed": self.derive_speed,
|
||||
"gross_error_enabled": self.gross_error_enabled,
|
||||
"gross_error_probability": round(self.gross_error_probability, 3),
|
||||
"gross_error_m": round(self.gross_error_m, 1),
|
||||
}
|
||||
|
||||
def update_tuning(self, drift_enabled: Any = _UNSET, drift_m: Any = _UNSET,
|
||||
speed_jitter_enabled: Any = _UNSET, speed_jitter: Any = _UNSET,
|
||||
timing_jitter_enabled: Any = _UNSET, derive_speed: Any = _UNSET,
|
||||
gross_error_enabled: Any = _UNSET, gross_error_probability: Any = _UNSET,
|
||||
gross_error_m: Any = _UNSET) -> dict[str, Any]:
|
||||
"""运行中实时调整(不影响正在播放的轨迹推进,只影响后续下发/速度)。
|
||||
|
||||
参数默认值是哨兵 `_UNSET` 而不是 None —— 否则无法把开关显式设成 False
|
||||
(None 与 False 都会被当成"未提供",开关就永远关不掉)。
|
||||
"""
|
||||
with self._lock:
|
||||
if drift_enabled is not _UNSET:
|
||||
self.drift_enabled = bool(drift_enabled)
|
||||
if timing_jitter_enabled is not _UNSET:
|
||||
self.timing_jitter_enabled = bool(timing_jitter_enabled)
|
||||
if derive_speed is not _UNSET:
|
||||
self.derive_speed = bool(derive_speed)
|
||||
if gross_error_enabled is not _UNSET:
|
||||
self.gross_error_enabled = bool(gross_error_enabled)
|
||||
if gross_error_probability is not _UNSET:
|
||||
value = protocol.finite(gross_error_probability, "粗差概率")
|
||||
if not (0.0 <= value <= 0.5):
|
||||
raise protocol.AppError("粗差概率需在 0 ~ 0.5 之间", "bad_tuning")
|
||||
self.gross_error_probability = value
|
||||
if gross_error_m is not _UNSET:
|
||||
value = protocol.finite(gross_error_m, "粗差幅度")
|
||||
if not (1.0 <= value <= 500.0):
|
||||
raise protocol.AppError("粗差幅度需在 1 ~ 500 米之间", "bad_tuning")
|
||||
self.gross_error_m = value
|
||||
if drift_m is not _UNSET:
|
||||
value = protocol.finite(drift_m, "漂移半径")
|
||||
if not (0.0 <= value <= noise.MAX_DRIFT_M):
|
||||
raise protocol.AppError(
|
||||
f"漂移半径需在 0 ~ {noise.MAX_DRIFT_M:g} 米之间", "bad_tuning"
|
||||
)
|
||||
self.drift_m = value
|
||||
if speed_jitter_enabled is not _UNSET:
|
||||
self.speed_jitter_enabled = bool(speed_jitter_enabled)
|
||||
if speed_jitter is not _UNSET:
|
||||
value = protocol.finite(speed_jitter, "速度浮动幅度")
|
||||
# 允许前端传百分数(如 8 表示 8%),但不能把"500%"悄悄当成"5%"
|
||||
if 1.0 < value <= 100.0:
|
||||
value = value / 100.0
|
||||
if not (0.0 <= value <= noise.MAX_SPEED_JITTER):
|
||||
raise protocol.AppError(
|
||||
f"速度浮动幅度需在 0 ~ {noise.MAX_SPEED_JITTER * 100:g}% 之间,"
|
||||
"或 0 ~ 1 的小数",
|
||||
"bad_tuning",
|
||||
)
|
||||
self.speed_jitter = value
|
||||
result = self.tuning()
|
||||
# 落盘,下次启动沿用(用户按校园跑场景调好的参数不该丢)
|
||||
try:
|
||||
self.config.update({
|
||||
"drift_enabled": result["drift_enabled"],
|
||||
"drift_m": result["drift_m"],
|
||||
"speed_jitter_enabled": result["speed_jitter_enabled"],
|
||||
"speed_jitter": result["speed_jitter"],
|
||||
"timing_jitter_enabled": result["timing_jitter_enabled"],
|
||||
"derive_speed": result["derive_speed"],
|
||||
"gross_error_enabled": result["gross_error_enabled"],
|
||||
"gross_error_probability": result["gross_error_probability"],
|
||||
"gross_error_m": result["gross_error_m"],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
self._emit_status(force=True)
|
||||
return result
|
||||
|
||||
def _speed_factor(self) -> float:
|
||||
"""当前速度乘性系数(1.0 = 不浮动)。"""
|
||||
if not self.speed_jitter_enabled or self.speed_jitter <= 0:
|
||||
return 1.0
|
||||
return self._drift.speed_factor(
|
||||
time.time() - self.started_at, self.speed_jitter, noise.DEFAULT_DRIFT_PERIOD_S
|
||||
)
|
||||
|
||||
def _reported_point(self, route: route_engine.Route,
|
||||
point: tuple[float, float]) -> tuple[float, float]:
|
||||
"""把"轨迹上的真实位置"加上多尺度漂移(可选粗差),得到实际下发给手机的位置。"""
|
||||
lat, lng = point
|
||||
if not self.drift_enabled or self.drift_m <= 0:
|
||||
self.last_drift_m = 0.0
|
||||
dlat = dlng = 0.0
|
||||
else:
|
||||
heading = route.heading_at_distance(self.distance)
|
||||
dlat, dlng = self._drift.offset_latlng(lat, lng, self.distance, heading, self.drift_m)
|
||||
# 粗差(默认关闭):偶发坏点,用来测自己 App 的精度过滤/跳点剔除
|
||||
if self.gross_error_enabled and self.gross_error_probability > 0:
|
||||
east_m, north_m = self._drift.gross_error_m(
|
||||
self.send_index, self.gross_error_probability, self.gross_error_m
|
||||
)
|
||||
if east_m or north_m:
|
||||
cos_lat = math.cos(math.radians(max(-85.0, min(85.0, lat))))
|
||||
dlat += north_m / 111320.0
|
||||
dlng += east_m / (111320.0 * max(0.05, cos_lat))
|
||||
if dlat == 0.0 and dlng == 0.0:
|
||||
self.last_drift_m = 0.0
|
||||
return point
|
||||
self.last_drift_m = geoutils.haversine(lat, lng, lat + dlat, lng + dlng)
|
||||
return (lat + dlat, lng + dlng)
|
||||
|
||||
# ---------------- 事件日志(仅用户主动操作时调用,不在循环里) ----------------
|
||||
def log_tuning(self) -> None:
|
||||
state = self.tuning()
|
||||
drift = f"{state['drift_m']:g} 米" if state["drift_enabled"] else "关闭"
|
||||
jitter = (
|
||||
f"±{state['speed_jitter_percent']:g}%" if state["speed_jitter_enabled"] else "关闭"
|
||||
)
|
||||
self._log("info", f"真实感设置:定位漂移 {drift},速度浮动 {jitter}")
|
||||
|
||||
# ---------------- 控制 ----------------
|
||||
def start(self, route: route_engine.Route, speed_scale: float | None = None,
|
||||
loop: bool | None = None, start_index: int = 0, provider: Any = None) -> None:
|
||||
with self._lock:
|
||||
if provider is not None:
|
||||
self._provider = provider
|
||||
old_thread = self._thread
|
||||
self.stop_locked(notify=False)
|
||||
generation = self._generation + 1
|
||||
self._generation = generation
|
||||
self._route = route
|
||||
self.speed_scale = protocol.validate_speed_scale(
|
||||
speed_scale if speed_scale is not None else route.speed_scale or 1.0
|
||||
)
|
||||
self.loop = bool(route.loop if loop is None else loop)
|
||||
self.playing = True
|
||||
self.paused = False
|
||||
self.error = None
|
||||
self.sent_count = 0
|
||||
self.source = "play"
|
||||
acc = route.dense_acc
|
||||
total = acc[-1] if acc else 0.0
|
||||
index = max(0, min(int(start_index), max(0, len(route.path) - 1)))
|
||||
self.distance = acc[index] if acc else 0.0
|
||||
self.tick_time = self._time_at_distance(self.distance)
|
||||
self.started_at = time.time()
|
||||
self.last_status_at = 0.0
|
||||
# 用当前时刻初始化,使首个间隔就是正常节奏(否则会在 50ms 后立刻补发第二个点)
|
||||
self.last_sent_at = time.time()
|
||||
self.last_report_mono = 0.0
|
||||
self.last_reported = None
|
||||
self.send_index = 0
|
||||
self.last_point = route.point_at_distance(self.distance) if route.path else None
|
||||
self._stop_flag.clear()
|
||||
self._wake.clear()
|
||||
self._cleared = False
|
||||
self._pending_dispatch = None
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, args=(generation,), name=f"playback-{generation}", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
# 旧线程若还卡在慢速下发里,等它退出(最多 6 秒);它每轮都会检查代次并自行结束。
|
||||
if old_thread is not None and old_thread.is_alive() and old_thread is not threading.current_thread():
|
||||
old_thread.join(timeout=6.0)
|
||||
if old_thread.is_alive():
|
||||
self._log("warn", "上一次播放的下发仍在收尾,已让新播放接替(旧线程会自行退出)")
|
||||
if route.distance <= 0 and len(route.path) <= 1:
|
||||
self._log("warn", "轨迹只有一个点:已把设备定位到该点")
|
||||
self._emit_status(force=True)
|
||||
|
||||
def pause(self) -> None:
|
||||
with self._lock:
|
||||
if not self.playing or self.paused:
|
||||
return
|
||||
self.paused = True
|
||||
self._log("info", "播放已暂停")
|
||||
self._emit_status(force=True)
|
||||
|
||||
def resume(self) -> None:
|
||||
with self._lock:
|
||||
if not self.playing or not self.paused:
|
||||
return
|
||||
self.paused = False
|
||||
self.last_sent_at = 0.0
|
||||
self._wake.set()
|
||||
self._log("info", "继续播放")
|
||||
self._emit_status(force=True)
|
||||
|
||||
def stop(self, notify: bool = True, clear_provider: bool = True) -> None:
|
||||
with self._lock:
|
||||
provider = self._provider if clear_provider else None
|
||||
already_cleared = self._cleared
|
||||
route = self._route
|
||||
old_thread = self.stop_locked(notify=notify)
|
||||
self._cleared = True
|
||||
if provider is not None and not already_cleared:
|
||||
# 恢复真实定位放到后台线程:不要让「停止」的应答等设备往返(实测会拖慢 2~3 秒,
|
||||
# 界面要过几秒才变回"未播放")。清理失败只记日志,不影响停止语义。
|
||||
threading.Thread(
|
||||
target=self._clear_provider_async, args=(provider, old_thread),
|
||||
name="playback-clear", daemon=True,
|
||||
).start()
|
||||
if notify and route is not None:
|
||||
self._emit_status(force=True)
|
||||
self.on_location({
|
||||
"lng": None, "lat": None, "source": "cleared", "progress": 0.0,
|
||||
"index": 0, "speed": 0.0, "sent_at": protocol.now_ts(),
|
||||
})
|
||||
|
||||
def _clear_provider_async(self, provider: Any, old_thread: threading.Thread | None = None) -> None:
|
||||
try:
|
||||
# 关键顺序保证:必须等在途的 set_location 真正结束之后再 clear,
|
||||
# 否则(对"帧结束时才生效"的实现)可能变成 clear 之后又来一次 set,
|
||||
# 设备就停在模拟点上而界面显示"已恢复真实定位"。
|
||||
if old_thread is not None and old_thread.is_alive():
|
||||
old_thread.join(timeout=6.0)
|
||||
pending = self._pending_dispatch
|
||||
if pending is not None:
|
||||
try:
|
||||
pending.result(timeout=6.0)
|
||||
except Exception:
|
||||
pass
|
||||
self.runner.run(provider.clear_location(), timeout=8.0, wait=True)
|
||||
self._log("success", "已停止模拟,iPhone 恢复真实定位")
|
||||
except Exception as exc:
|
||||
self._log("warn", f"恢复真实定位失败:{_short(exc)}")
|
||||
|
||||
def stop_locked(self, notify: bool = True, join_timeout: float = 0.6) -> threading.Thread | None:
|
||||
self._stop_flag.set()
|
||||
self._wake.set()
|
||||
# 代次自增:即便旧线程还卡在慢速下发里,它恢复后也会因为代次过期而立即退出
|
||||
self._generation += 1
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
self.playing = False
|
||||
self.paused = False
|
||||
if thread is not None and thread.is_alive() and thread is not threading.current_thread():
|
||||
# 不长时间等待:代次机制已经保证旧线程不会再下发/推送,界面无需为它等待。
|
||||
# 锁外 join,避免持锁等待阻塞其它命令(如 play.start / 状态查询)。
|
||||
self._lock.release()
|
||||
try:
|
||||
thread.join(timeout=join_timeout)
|
||||
finally:
|
||||
self._lock.acquire()
|
||||
return thread
|
||||
|
||||
def seek(self, progress: float) -> dict[str, Any]:
|
||||
route = self.route
|
||||
if route is None or not route.path:
|
||||
raise protocol.AppError("当前没有正在播放的轨迹", "no_route")
|
||||
# 播放已结束/已停止时不允许 seek:否则会在"未播放"状态下重新把设备定位过去
|
||||
with self._lock:
|
||||
if not (self.playing or self.paused):
|
||||
raise protocol.AppError("当前没有正在播放的轨迹(请先点播放)", "not_playing")
|
||||
value = protocol.finite(progress, "进度")
|
||||
value = protocol.clamp(value, 0.0, 1.0)
|
||||
with self._lock:
|
||||
acc = route.dense_acc
|
||||
total = acc[-1] if acc else 0.0
|
||||
self.distance = total * value
|
||||
self.tick_time = self._time_at_distance(self.distance)
|
||||
self.last_sent_at = 0.0
|
||||
self._wake.set()
|
||||
point = route.point_at_distance(self.distance)
|
||||
self._send_point(point, force=True)
|
||||
self._emit_status(force=True)
|
||||
return self.status()
|
||||
|
||||
def set_speed(self, speed_scale: float) -> dict[str, Any]:
|
||||
value = protocol.validate_speed_scale(speed_scale)
|
||||
with self._lock:
|
||||
self.speed_scale = value
|
||||
self._emit_status(force=True)
|
||||
return self.status()
|
||||
|
||||
def set_loop(self, loop: bool) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self.loop = bool(loop)
|
||||
self._emit_status(force=True)
|
||||
return self.status()
|
||||
|
||||
# ---------------- 单点定位 ----------------
|
||||
def set_point(self, lat: float, lng: float, provider: Any = None) -> None:
|
||||
with self._lock:
|
||||
if provider is not None:
|
||||
self._provider = provider
|
||||
self.source = "manual"
|
||||
self.last_point = (lat, lng)
|
||||
self.last_sent_at = time.time()
|
||||
self._dispatch(lat, lng, source="manual", progress=0.0, index=0, speed=0.0)
|
||||
|
||||
def clear_point(self, provider: Any = None) -> None:
|
||||
with self._lock:
|
||||
if provider is not None:
|
||||
self._provider = provider
|
||||
self.source = "manual"
|
||||
self.stop(notify=True, clear_provider=True)
|
||||
|
||||
# ---------------- worker ----------------
|
||||
def _loop(self, generation: int) -> None:
|
||||
def stale() -> bool:
|
||||
"""自己是不是已被更新的播放/停止取代。"""
|
||||
with self._lock:
|
||||
return generation != self._generation or self._stop_flag.is_set()
|
||||
|
||||
route = self.route
|
||||
if route is None:
|
||||
return
|
||||
send_hz = float(self.config.get("send_hz") or protocol.DEFAULT_SEND_HZ)
|
||||
send_interval = 1.0 / protocol.clamp(send_hz, protocol.MIN_SEND_HZ, protocol.MAX_SEND_HZ)
|
||||
last = time.monotonic()
|
||||
stall_logged = False
|
||||
try:
|
||||
# 先立刻把起点发出去,用户点击播放后马上生效
|
||||
self._send_point(route.point_at_distance(self.distance), force=True, generation=generation)
|
||||
while not stale():
|
||||
self._wake.wait(TICK)
|
||||
self._wake.clear()
|
||||
now = time.monotonic()
|
||||
delta = min(MAX_CATCHUP, max(0.0, now - last))
|
||||
last = now
|
||||
if stale():
|
||||
break
|
||||
paused = self.paused
|
||||
finished = False
|
||||
with self._lock:
|
||||
if not self.playing:
|
||||
break
|
||||
if not paused and route.path:
|
||||
acc = route.dense_acc
|
||||
total = acc[-1] if acc else 0.0
|
||||
if total > 0:
|
||||
speed = route.speed_at_distance(self.distance)
|
||||
# 速度浮动:让瞬时速度在设定值上下平滑浮动(校园跑场景的关键真实感)。
|
||||
# 同一个 tick 内只算一次系数,推进与上报必须用同一个值,否则上报速度会系统性偏高。
|
||||
self._speed_factor_now = self._speed_factor()
|
||||
step = max(0.05, speed) * self.speed_scale * self._speed_factor_now * delta
|
||||
self.distance += step
|
||||
self.tick_time += delta * self.speed_scale
|
||||
if self.distance >= total:
|
||||
if self.loop:
|
||||
self.distance = self.distance % total if total > 0 else 0.0
|
||||
else:
|
||||
self.distance = total
|
||||
finished = True
|
||||
else:
|
||||
finished = not self.loop
|
||||
if not paused:
|
||||
point = route.point_at_distance(self.distance)
|
||||
self.last_point = point
|
||||
if not paused:
|
||||
self._maybe_send(route, send_interval, generation=generation)
|
||||
if stale():
|
||||
break
|
||||
if finished:
|
||||
self._log("success", f"轨迹播放完成:{_fmt_distance(route.distance)},共下发 {self.sent_count} 次定位")
|
||||
with self._lock:
|
||||
if generation == self._generation:
|
||||
self.playing = False
|
||||
self.paused = False
|
||||
self._emit_status(force=True)
|
||||
break
|
||||
self._emit_status()
|
||||
except Exception as exc: # 不让线程静默死掉
|
||||
self.error = _short(exc)
|
||||
self._log("error", f"播放线程异常:{self.error}")
|
||||
with self._lock:
|
||||
self.playing = False
|
||||
self.paused = False
|
||||
self._emit_status(force=True)
|
||||
if not stall_logged:
|
||||
stall_logged = True
|
||||
|
||||
def _maybe_send(self, route: route_engine.Route, send_interval: float, generation: int | None = None) -> None:
|
||||
now = time.time()
|
||||
# 真实 GPS 的回调不是精确等间隔:加节奏抖动(含偶发丢采样),
|
||||
# 间隔完全均匀(精确 1.000s)是最容易被识别的特征之一。
|
||||
jitter_factor = self._drift.timing_factor(self.send_index) if self.timing_jitter_enabled else 1.0
|
||||
wait = send_interval * jitter_factor
|
||||
if self.last_sent_at and (now - self.last_sent_at) < wait:
|
||||
return
|
||||
with self._lock:
|
||||
point = route.point_at_distance(self.distance)
|
||||
self.last_sent_at = now
|
||||
self._send_point(point, generation=generation)
|
||||
|
||||
def _send_point(self, point: tuple[float, float], force: bool = False, generation: int | None = None) -> None:
|
||||
route = self.route
|
||||
if point is None:
|
||||
return
|
||||
total = route.distance if route is not None else 0.0
|
||||
progress = 0.0
|
||||
speed = 0.0
|
||||
index = 0
|
||||
if route is not None and route.path:
|
||||
acc = route.dense_acc
|
||||
total = acc[-1] if acc else 0.0
|
||||
progress = protocol.clamp(self.distance / total, 0.0, 1.0) if total > 0 else 1.0
|
||||
index = _index_of(acc, self.distance)
|
||||
reported = self._reported_point(route, point) if route is not None else point
|
||||
# ---- 上报速度:优先由"实际下发点之间的位移 ÷ 实际时间"推算 ----
|
||||
# 这与 iOS 从坐标序列推算 CLLocation.speed 的方式一致,因此速度、位移、
|
||||
# 时间戳三者天然自洽;不会出现"上报速度与轨迹推进矛盾"这种自曝式数据。
|
||||
now = time.time()
|
||||
mono = time.monotonic()
|
||||
derive = self.derive_speed and self.last_reported is not None and self.last_report_mono > 0
|
||||
if derive:
|
||||
prev_lat, prev_lng = self.last_reported
|
||||
dt = mono - self.last_report_mono
|
||||
jump = geoutils.haversine(prev_lat, prev_lng, reported[0], reported[1])
|
||||
if dt >= 0.05:
|
||||
if jump > 400.0:
|
||||
# 粗差/跳点:真实接收机此时 speed 无效(-1),而不是给出一个巨大速度
|
||||
speed = -1.0
|
||||
else:
|
||||
speed = jump / dt
|
||||
else:
|
||||
speed = 0.0
|
||||
self.last_speed_jump = jump
|
||||
self.last_speed_dt = dt
|
||||
else:
|
||||
factor = getattr(self, "_speed_factor_now", None)
|
||||
if factor is None:
|
||||
factor = self._speed_factor()
|
||||
speed = (route.speed_at_distance(self.distance) * factor) if route is not None else 0.0
|
||||
with self._lock:
|
||||
self.last_reported = (reported[0], reported[1])
|
||||
self.last_report_mono = mono
|
||||
self.send_index += 1
|
||||
self._dispatch(
|
||||
reported[0], reported[1], source="play", progress=progress, index=index,
|
||||
speed=speed, generation=generation,
|
||||
)
|
||||
|
||||
def _dispatch(self, lat: float, lng: float, source: str, progress: float, index: int, speed: float,
|
||||
generation: int | None = None) -> None:
|
||||
provider = self._provider
|
||||
if provider is None:
|
||||
self._log("error", "尚未连接设备:无法下发定位")
|
||||
with self._lock:
|
||||
self.playing = False
|
||||
return
|
||||
try:
|
||||
future = self.runner.run(provider.set_location(lat, lng), timeout=6.0, wait=True)
|
||||
self._pending_dispatch = future
|
||||
except Exception as exc:
|
||||
message = _short(exc)
|
||||
self._log("error", f"下发定位失败:{message}")
|
||||
with self._lock:
|
||||
self.error = message
|
||||
if generation is None or generation == self._generation:
|
||||
self.playing = False
|
||||
self.paused = False
|
||||
self._emit_status(force=True)
|
||||
self._stop_flag.set()
|
||||
return
|
||||
# 下发期间可能已经被停止/被新的播放取代:这次结果作废,且不再推送位置事件
|
||||
with self._lock:
|
||||
if generation is not None and generation != self._generation:
|
||||
return
|
||||
self.sent_count += 1
|
||||
# 逐次下发的日志按 5 秒聚合,避免 5Hz 刷屏(前端日志面板与 WS 带宽都受不了)
|
||||
now = time.time()
|
||||
if now - getattr(self, "_last_send_log", 0.0) >= 5.0:
|
||||
self._last_send_log = now
|
||||
self._log("info", f"正在按轨迹下发定位(已下发 {self.sent_count} 次)")
|
||||
self.on_location({
|
||||
"lng": protocol.round6(lng),
|
||||
"lat": protocol.round6(lat),
|
||||
"source": source,
|
||||
"progress": round(progress, 5),
|
||||
"index": index,
|
||||
"speed": round(speed, 3),
|
||||
"drift_m": round(self.last_drift_m, 2),
|
||||
"sent_at": protocol.now_ts(),
|
||||
})
|
||||
|
||||
# ---------------- 状态 ----------------
|
||||
def _time_at_distance(self, distance: float) -> float:
|
||||
route = self.route
|
||||
if route is None or not route.path or not route.speeds:
|
||||
return 0.0
|
||||
acc = route.dense_acc
|
||||
total = 0.0
|
||||
for i in range(1, len(route.path)):
|
||||
seg = acc[i] - acc[i - 1]
|
||||
if acc[i - 1] >= distance:
|
||||
break
|
||||
if acc[i] <= distance:
|
||||
total += seg / max(0.05, (route.speeds[i] + route.speeds[i - 1]) / 2.0)
|
||||
else:
|
||||
part = max(0.0, distance - acc[i - 1])
|
||||
total += part / max(0.05, route.speeds[i])
|
||||
break
|
||||
return total
|
||||
|
||||
def status(self, force: bool = False) -> dict[str, Any]:
|
||||
route = self.route
|
||||
with self._lock:
|
||||
total_distance = route.distance if route is not None else 0.0
|
||||
progress = protocol.clamp(self.distance / total_distance, 0.0, 1.0) if total_distance > 0 else (1.0 if route else 0.0)
|
||||
elapsed = self.tick_time
|
||||
duration = route.duration if route is not None else 0.0
|
||||
remaining = max(0.0, duration - elapsed)
|
||||
point = self.last_point
|
||||
return {
|
||||
"playing": bool(self.playing),
|
||||
"paused": bool(self.paused),
|
||||
"progress": round(progress, 5),
|
||||
"index": 0 if route is None else _index_of(route.dense_acc, self.distance),
|
||||
"total": 0 if route is None else len(route.path),
|
||||
"elapsed": round(elapsed, 2),
|
||||
"remaining": round(remaining, 2),
|
||||
"duration": round(duration, 2),
|
||||
"distance_done": round(self.distance, 2),
|
||||
"distance": round(total_distance, 2),
|
||||
"speed_scale": self.speed_scale,
|
||||
"loop": self.loop,
|
||||
"lng": protocol.round6(point[1]) if point else None,
|
||||
"lat": protocol.round6(point[0]) if point else None,
|
||||
"name": route.name if route is not None else None,
|
||||
"error": self.error,
|
||||
"sent_count": self.sent_count,
|
||||
"tuning": self.tuning(),
|
||||
"drift_m": round(self.last_drift_m, 2),
|
||||
}
|
||||
|
||||
def _emit_status(self, force: bool = False) -> None:
|
||||
now = time.time()
|
||||
if not force and (now - self.last_status_at) < STATUS_INTERVAL:
|
||||
return
|
||||
self.last_status_at = now
|
||||
self.status_seq += 1
|
||||
try:
|
||||
self.on_status({**self.status(force=force), "seq": self.status_seq})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _log(self, level: str, message: str) -> None:
|
||||
if self.on_log:
|
||||
try:
|
||||
self.on_log(level, message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _index_of(acc: list[float], distance: float) -> int:
|
||||
if not acc:
|
||||
return 0
|
||||
lo, hi = 0, len(acc) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if acc[mid] <= distance:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return max(0, min(lo - 1, len(acc) - 1))
|
||||
|
||||
|
||||
def _short(exc: Exception) -> str:
|
||||
text = str(exc).strip() or exc.__class__.__name__
|
||||
return text[:200]
|
||||
|
||||
|
||||
def _fmt_distance(meters: float) -> str:
|
||||
if meters >= 1000:
|
||||
return f"{meters / 1000:.2f} 公里"
|
||||
return f"{meters:.0f} 米"
|
||||
|
||||
|
||||
__all__ = ["PlaybackEngine", "geoutils"]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""公共常量与工具:前后端接口契约见 docs/PROTOCOL.md。
|
||||
|
||||
本模块不依赖任何第三方库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "iOS 虚拟定位轨迹工具"
|
||||
APP_VERSION = "1.0.0"
|
||||
PROTOCOL_VERSION = "v1"
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8787
|
||||
|
||||
# 地理常量
|
||||
EARTH_RADIUS_M = 6371008.8
|
||||
|
||||
# 播放引擎默认参数
|
||||
DEFAULT_SEND_HZ = 5.0
|
||||
MIN_SEND_HZ = 1.0
|
||||
MAX_SEND_HZ = 20.0
|
||||
|
||||
MODE_DEFAULTS = {
|
||||
"driving": 13.9, # m/s ≈ 50 km/h
|
||||
"cycling": 5.5, # m/s ≈ 20 km/h
|
||||
"walking": 1.4, # m/s ≈ 5 km/h
|
||||
"flight": 60.0, # m/s ≈ 216 km/h
|
||||
}
|
||||
|
||||
MODE_LABELS = {
|
||||
"driving": "驾车",
|
||||
"cycling": "骑行",
|
||||
"walking": "步行",
|
||||
"flight": "飞行",
|
||||
}
|
||||
|
||||
MIN_SPEED_SCALE = 0.1
|
||||
MAX_SPEED_SCALE = 50.0
|
||||
|
||||
# 单点定位与轨迹点的合法范围
|
||||
MIN_LAT, MAX_LAT = -85.05112878, 85.05112878
|
||||
MIN_LNG, MAX_LNG = -180.0, 180.0
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""带机器码与中文提示的业务异常。"""
|
||||
|
||||
def __init__(self, message: str, code: str = "app_error", status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"ok": False, "error": self.message, "code": self.code}
|
||||
|
||||
|
||||
def now_ts() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def round6(value: float) -> float:
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def clamp(value: float, low: float, high: float) -> float:
|
||||
"""夹取到 [low, high]。NaN 会直接抛错——NaN 参与比较恒为 False,会静默污染整条链路。"""
|
||||
if not math.isfinite(value):
|
||||
raise AppError("数值不是有限数(不允许 NaN / Infinity)", "bad_number")
|
||||
if value < low:
|
||||
return low
|
||||
if value > high:
|
||||
return high
|
||||
return value
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:10]}"
|
||||
|
||||
|
||||
def dumps(obj: Any) -> str:
|
||||
"""序列化为 JSON。
|
||||
|
||||
allow_nan=False:任何 NaN/Infinity 都会抛错而不是产出 `NaN` 字面量——
|
||||
后者会让浏览器 JSON.parse 直接失败,把整帧数据丢掉。
|
||||
"""
|
||||
try:
|
||||
return json.dumps(
|
||||
obj, ensure_ascii=False, separators=(",", ":"), default=_json_default, allow_nan=False
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AppError(f"内部数据包含非法数值(NaN/Infinity),已拒绝下发:{exc}", "bad_number") from exc
|
||||
|
||||
|
||||
def finite(value: Any, field: str = "数值") -> float:
|
||||
"""校验并返回有限浮点数。"""
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AppError(f"{field}必须是数字", "bad_number") from exc
|
||||
if not math.isfinite(number):
|
||||
raise AppError(f"{field}不是有限数(不允许 NaN / Infinity)", "bad_number")
|
||||
return number
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
if isinstance(obj, (set, tuple)):
|
||||
return list(obj)
|
||||
if hasattr(obj, "isoformat"):
|
||||
return obj.isoformat()
|
||||
return str(obj)
|
||||
|
||||
|
||||
def loads(raw: str | bytes) -> Any:
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", "replace")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def validate_latlng(lat: Any, lng: Any) -> tuple[float, float]:
|
||||
"""校验并归一化经纬度,返回 (lat, lng)。"""
|
||||
try:
|
||||
flat = float(lat)
|
||||
flng = float(lng)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AppError("经纬度必须是数字", "bad_coords") from exc
|
||||
if math.isnan(flat) or math.isnan(flng) or math.isinf(flat) or math.isinf(flng):
|
||||
raise AppError("经纬度不是有效数字", "bad_coords")
|
||||
if not (MIN_LAT <= flat <= MAX_LAT):
|
||||
raise AppError(f"纬度超出有效范围({MIN_LAT:.4f} ~ {MAX_LAT:.4f})", "bad_coords")
|
||||
if not (MIN_LNG <= flng <= MAX_LNG):
|
||||
raise AppError("经度超出有效范围(-180 ~ 180)", "bad_coords")
|
||||
return flat, flng
|
||||
|
||||
|
||||
def validate_speed_scale(value: Any) -> float:
|
||||
scale = finite(value, "倍速")
|
||||
if scale <= 0:
|
||||
raise AppError("倍速必须大于 0", "bad_speed")
|
||||
if not (MIN_SPEED_SCALE <= scale <= MAX_SPEED_SCALE):
|
||||
raise AppError(
|
||||
f"倍速需在 {MIN_SPEED_SCALE} ~ {MAX_SPEED_SCALE} 之间", "bad_speed"
|
||||
)
|
||||
return scale
|
||||
|
||||
|
||||
def validate_mode(mode: Any) -> str:
|
||||
if mode is None:
|
||||
return "driving"
|
||||
text = str(mode).strip().lower()
|
||||
if text not in MODE_DEFAULTS:
|
||||
raise AppError(
|
||||
"出行方式只支持 driving / cycling / walking / flight", "bad_mode"
|
||||
)
|
||||
return text
|
||||
@@ -0,0 +1,576 @@
|
||||
"""路线引擎:途经点 → 规划路线 → 加密路径 → 速度剖面 → GPX 导入导出。
|
||||
|
||||
输出结构(docs/PROTOCOL.md 2.2):
|
||||
{"waypoints": [[lng,lat],...], "path": [[lng,lat],...], "speeds": [...],
|
||||
"distance": m, "duration": s, "engine": "osrm"|"straight"|"manual", ...}
|
||||
播放引擎会用 Route.dense_acc 做弧长插值。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from . import geoutils, protocol
|
||||
|
||||
MAX_PATH_POINTS = 60000
|
||||
DEFAULT_STEP_M = 2.0
|
||||
_OSRM_TIMEOUT = 8.0
|
||||
_UA = "iOSLocationTool/1.0 (+local mac app)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
name: str = "未命名轨迹"
|
||||
mode: str = "driving"
|
||||
waypoints: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
|
||||
path: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
|
||||
speeds: list[float] = field(default_factory=list) # m/s
|
||||
engine: str = "straight"
|
||||
loop: bool = False
|
||||
speed_scale: float = 1.0
|
||||
_acc: list[float] = field(default_factory=list, repr=False)
|
||||
|
||||
# ---------- 派生属性 ----------
|
||||
@property
|
||||
def dense_acc(self) -> list[float]:
|
||||
if not self._acc and self.path:
|
||||
self._acc = geoutils.cumulative_lengths(self.path)
|
||||
return self._acc
|
||||
|
||||
@property
|
||||
def distance(self) -> float:
|
||||
acc = self.dense_acc
|
||||
return acc[-1] if acc else 0.0
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""按速度剖面积分得到的实际耗时(秒)。"""
|
||||
if len(self.path) < 2 or not self.speeds:
|
||||
return 0.0
|
||||
acc = self.dense_acc
|
||||
total = 0.0
|
||||
for i in range(1, len(self.path)):
|
||||
seg = acc[i] - acc[i - 1]
|
||||
v = (self.speeds[i] + self.speeds[i - 1]) / 2.0
|
||||
total += seg / v if v > 0.05 else seg / 0.05
|
||||
return total
|
||||
|
||||
def point_at_distance(self, distance: float) -> tuple[float, float]:
|
||||
if not self.path:
|
||||
raise protocol.AppError("轨迹为空", "empty_route")
|
||||
acc = self.dense_acc
|
||||
point, _ = geoutils.interpolate(self.path, acc, distance)
|
||||
return point
|
||||
|
||||
def speed_at_distance(self, distance: float) -> float:
|
||||
if not self.speeds or len(self.path) < 2:
|
||||
return 0.0
|
||||
acc = self.dense_acc
|
||||
idx = _index_of(acc, distance)
|
||||
return self.speeds[min(idx, len(self.speeds) - 1)]
|
||||
|
||||
def heading_at_distance(self, distance: float) -> float:
|
||||
"""行进方向(度,0=正北)。用于让漂移偏移相对于行进方向计算。"""
|
||||
if len(self.path) < 2:
|
||||
return 0.0
|
||||
acc = self.dense_acc
|
||||
idx = _index_of(acc, distance)
|
||||
idx = max(0, min(idx, len(self.path) - 2))
|
||||
a = self.path[idx]
|
||||
b = self.path[idx + 1]
|
||||
return geoutils.bearing(a[0], a[1], b[0], b[1])
|
||||
|
||||
def highlight(self, distance: float) -> list[list[float]]:
|
||||
acc = self.dense_acc
|
||||
pts = geoutils.slice_path(self.path, acc, distance) if len(self.path) > 1 else list(self.path)
|
||||
return [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in pts]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"mode": self.mode,
|
||||
"waypoints": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.waypoints],
|
||||
"path": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.path],
|
||||
"speeds": [round(v, 3) for v in self.speeds],
|
||||
"distance": round(self.distance, 2),
|
||||
"duration": round(self.duration, 2),
|
||||
"engine": self.engine,
|
||||
"loop": self.loop,
|
||||
"speed_scale": self.speed_scale,
|
||||
}
|
||||
|
||||
|
||||
def _index_of(acc: list[float], distance: float) -> int:
|
||||
lo, hi = 0, len(acc) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if acc[mid] <= distance:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return max(0, lo - 1)
|
||||
|
||||
|
||||
class RouteEngine:
|
||||
def __init__(self, config: Any) -> None:
|
||||
self.config = config
|
||||
|
||||
# ---------------- 构建 ----------------
|
||||
def build(
|
||||
self,
|
||||
waypoint_payloads: list[Any],
|
||||
mode: str = "driving",
|
||||
name: str | None = None,
|
||||
use_osrm: bool = True,
|
||||
loop: bool = False,
|
||||
speed_scale: float = 1.0,
|
||||
) -> Route:
|
||||
mode = protocol.validate_mode(mode)
|
||||
speed_scale = protocol.validate_speed_scale(speed_scale)
|
||||
coords = parse_waypoints(waypoint_payloads)
|
||||
if len(coords) < 1:
|
||||
raise protocol.AppError("至少需要一个途经点", "no_waypoints")
|
||||
|
||||
engine = "straight"
|
||||
if len(coords) >= 2 and use_osrm:
|
||||
planned = self._osrm(coords, mode)
|
||||
if planned is not None:
|
||||
coords = planned
|
||||
engine = "osrm"
|
||||
|
||||
route = Route(
|
||||
name=name or "未命名轨迹",
|
||||
mode=mode,
|
||||
engine=engine,
|
||||
loop=bool(loop),
|
||||
speed_scale=speed_scale,
|
||||
)
|
||||
route.path = self._densify(coords)
|
||||
route.speeds = compute_speeds(route.path, mode, self._base_speed(mode))
|
||||
route._acc = geoutils.cumulative_lengths(route.path)
|
||||
route.waypoints = _pick_waypoints(route.path, parse_waypoints(waypoint_payloads))
|
||||
return route
|
||||
|
||||
def from_coords(
|
||||
self,
|
||||
coords: list[tuple[float, float]],
|
||||
mode: str = "driving",
|
||||
name: str = "导入轨迹",
|
||||
engine: str = "manual",
|
||||
loop: bool = False,
|
||||
speed_scale: float = 1.0,
|
||||
) -> Route:
|
||||
if len(coords) < 1:
|
||||
raise protocol.AppError("轨迹为空", "no_waypoints")
|
||||
route = Route(
|
||||
name=name,
|
||||
mode=protocol.validate_mode(mode),
|
||||
engine=engine,
|
||||
loop=bool(loop),
|
||||
speed_scale=protocol.validate_speed_scale(speed_scale),
|
||||
)
|
||||
route.path = self._densify(coords)
|
||||
route.speeds = compute_speeds(route.path, route.mode, self._base_speed(route.mode))
|
||||
route._acc = geoutils.cumulative_lengths(route.path)
|
||||
route.waypoints = _pick_waypoints(route.path, coords)
|
||||
return route
|
||||
|
||||
def _densify(self, coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
if len(coords) == 1:
|
||||
return list(coords)
|
||||
raw = geoutils.densify(coords, DEFAULT_STEP_M, smooth=False)
|
||||
if len(raw) <= MAX_PATH_POINTS:
|
||||
return raw
|
||||
# 超长时按比例降低采样密度,仍保证曲线形状
|
||||
rough = geoutils.cumulative_lengths(coords)[-1]
|
||||
step = max(DEFAULT_STEP_M, rough / MAX_PATH_POINTS * 1.05)
|
||||
out = geoutils.densify(coords, step, smooth=False)
|
||||
return out[:MAX_PATH_POINTS] if len(out) > MAX_PATH_POINTS else out
|
||||
|
||||
def _base_speed(self, mode: str) -> float:
|
||||
defaults = self.config.get("mode_defaults") or {}
|
||||
try:
|
||||
value = float(defaults.get(mode, protocol.MODE_DEFAULTS.get(mode, 13.9)))
|
||||
except (TypeError, ValueError):
|
||||
value = protocol.MODE_DEFAULTS.get(mode, 13.9)
|
||||
return max(0.2, value)
|
||||
|
||||
# ---------------- OSRM ----------------
|
||||
def _osrm(self, coords: list[tuple[float, float]], mode: str) -> list[tuple[float, float]] | None:
|
||||
profile = {"driving": "driving", "cycling": "bike", "walking": "foot", "flight": "driving"}[mode]
|
||||
if mode == "flight":
|
||||
return None
|
||||
base = str(self.config.get("osrm_url") or "").rstrip("/")
|
||||
if not base:
|
||||
return None
|
||||
coords_str = ";".join(f"{lng:.6f},{lat:.6f}" for lat, lng in coords)
|
||||
url = f"{base}/{profile}/{coords_str}?overview=full&geometries=geojson&steps=false"
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=_OSRM_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if payload.get("code") != "Ok":
|
||||
return None
|
||||
geometry = payload["routes"][0]["geometry"]["coordinates"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
out: list[tuple[float, float]] = []
|
||||
for item in geometry:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
try:
|
||||
out.append((float(item[1]), float(item[0])))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out if len(out) >= 2 else None
|
||||
|
||||
def osrm_available(self) -> bool:
|
||||
base = str(self.config.get("osrm_url") or "").rstrip("/")
|
||||
if not base:
|
||||
return False
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
base.replace("/route/v1", "") + "/route/v1/driving/116.39,39.90;116.40,39.91?overview=false",
|
||||
headers={"User-Agent": _UA},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=4.0) as response:
|
||||
return response.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------- 速度剖面 ----------------
|
||||
def compute_speeds(
|
||||
path: list[tuple[float, float]], mode: str, base_speed: float
|
||||
) -> list[float]:
|
||||
"""按转弯角度生成速度剖面,并做滑动平均让加减速自然。"""
|
||||
n = len(path)
|
||||
if n == 0:
|
||||
return []
|
||||
if n == 1:
|
||||
return [base_speed]
|
||||
if mode == "flight":
|
||||
return _smooth([base_speed] * n, window=1)
|
||||
|
||||
acc = geoutils.cumulative_lengths(path)
|
||||
raw = [base_speed] * n
|
||||
for i in range(1, n - 1):
|
||||
# 用前后各 ~12 米的点计算航向变化,避免逐点抖动
|
||||
a = _point_near(path, acc, max(0.0, acc[i] - 12.0))
|
||||
b = path[i]
|
||||
c = _point_near(path, acc, acc[i] + 12.0)
|
||||
angle = abs(_angle_diff(
|
||||
geoutils.bearing(a[0], a[1], b[0], b[1]),
|
||||
geoutils.bearing(b[0], b[1], c[0], c[1]),
|
||||
))
|
||||
factor = 1.0
|
||||
if angle >= 100:
|
||||
factor = 0.12
|
||||
elif angle >= 60:
|
||||
factor = 0.25
|
||||
elif angle >= 35:
|
||||
factor = 0.45
|
||||
elif angle >= 18:
|
||||
factor = 0.68
|
||||
elif angle >= 8:
|
||||
factor = 0.86
|
||||
raw[i] = base_speed * factor
|
||||
raw[0] = raw[1] if n > 1 else base_speed
|
||||
raw[-1] = raw[-2] if n > 1 else base_speed
|
||||
return _smooth(raw, window=max(3, min(41, n // 4 * 2 + 1)))
|
||||
|
||||
|
||||
def _point_near(
|
||||
path: list[tuple[float, float]], acc: list[float], distance: float
|
||||
) -> tuple[float, float]:
|
||||
point, _ = geoutils.interpolate(path, acc, distance)
|
||||
return point
|
||||
|
||||
|
||||
def _angle_diff(a: float, b: float) -> float:
|
||||
diff = (b - a + 540.0) % 360.0 - 180.0
|
||||
return diff
|
||||
|
||||
|
||||
def _smooth(values: list[float], window: int) -> list[float]:
|
||||
n = len(values)
|
||||
if n <= 2 or window <= 1:
|
||||
return [round(v, 3) for v in values]
|
||||
half = max(1, window // 2)
|
||||
out: list[float] = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
chunk = values[lo:hi]
|
||||
out.append(round(sum(chunk) / len(chunk), 3))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- 途经点处理 ----------------
|
||||
MAX_WAYPOINTS = 5000
|
||||
MAX_REQUEST_POINTS = 200000
|
||||
|
||||
|
||||
def parse_waypoints(payloads: Any, limit: int = MAX_WAYPOINTS) -> list[tuple[float, float]]:
|
||||
"""把 [[lng,lat], ...] 或 [{"lng":..,"lat":..}, ...] 统一成 [(lat,lng)]。"""
|
||||
if payloads is None:
|
||||
return []
|
||||
if not isinstance(payloads, (list, tuple)):
|
||||
raise protocol.AppError("waypoints 必须是数组", "bad_waypoints")
|
||||
if len(payloads) > limit:
|
||||
raise protocol.AppError(
|
||||
f"坐标点过多({len(payloads)} 个,上限 {limit} 个):请减少途经点或改用 GPX 导入",
|
||||
"too_many_points",
|
||||
status=413,
|
||||
)
|
||||
out: list[tuple[float, float]] = []
|
||||
for item in payloads:
|
||||
if isinstance(item, dict):
|
||||
lat = item.get("lat")
|
||||
lng = item.get("lng", item.get("lon"))
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
lng, lat = item[0], item[1]
|
||||
else:
|
||||
raise protocol.AppError("途经点格式应为 [经度, 纬度]", "bad_waypoints")
|
||||
flat, flng = protocol.validate_latlng(lat, lng)
|
||||
out.append((flat, flng))
|
||||
return out
|
||||
|
||||
|
||||
def _pick_waypoints(
|
||||
path: list[tuple[float, float]], wanted: list[tuple[float, float]]
|
||||
) -> list[tuple[float, float]]:
|
||||
"""用户可能只给了稀疏的途经点,这里保留原始途经点作为列表展示。"""
|
||||
return list(wanted) if wanted else list(path[:1] + path[-1:] if len(path) > 1 else path)
|
||||
|
||||
|
||||
# ---------------- GPX ----------------
|
||||
GPX_NS = "http://www.topografix.com/GPX/1/1"
|
||||
|
||||
|
||||
def to_gpx(route: Route, creator: str = protocol.APP_NAME) -> str:
|
||||
root = ET.Element(
|
||||
"gpx",
|
||||
{
|
||||
"version": "1.1",
|
||||
"creator": creator,
|
||||
"xmlns": GPX_NS,
|
||||
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
||||
"xsi:schemaLocation": f"{GPX_NS} http://www.topografix.com/GPX/1/1/gpx.xsd",
|
||||
},
|
||||
)
|
||||
metadata = ET.SubElement(root, "metadata")
|
||||
ET.SubElement(metadata, "name").text = route.name or "未命名轨迹"
|
||||
ET.SubElement(metadata, "desc").text = (
|
||||
f"{protocol.MODE_LABELS.get(route.mode, route.mode)}轨迹,"
|
||||
f"距离 {route.distance / 1000:.2f} km,预计耗时 {_fmt_duration(route.duration)}"
|
||||
)
|
||||
ET.SubElement(metadata, "time").text = _iso_now()
|
||||
|
||||
for index, (lat, lng) in enumerate(route.waypoints, start=1):
|
||||
wpt = ET.SubElement(root, "wpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
|
||||
ET.SubElement(wpt, "name").text = f"途经点 {index}"
|
||||
ET.SubElement(wpt, "sym").text = "Flag, Blue"
|
||||
|
||||
trk = ET.SubElement(root, "trk")
|
||||
ET.SubElement(trk, "name").text = route.name or "未命名轨迹"
|
||||
ET.SubElement(trk, "type").text = protocol.MODE_LABELS.get(route.mode, route.mode)
|
||||
seg = ET.SubElement(trk, "trkseg")
|
||||
acc = route.dense_acc
|
||||
for i, (lat, lng) in enumerate(route.path):
|
||||
trkpt = ET.SubElement(seg, "trkpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
|
||||
if i < len(route.speeds):
|
||||
ET.SubElement(trkpt, "speed").text = f"{route.speeds[i]:.3f}"
|
||||
if acc:
|
||||
ET.SubElement(trkpt, "time").text = _iso_offset(acc[i] / max(0.2, _avg_speed(route)))
|
||||
_indent(root)
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def from_gpx(text: str) -> tuple[list[tuple[float, float]], str, str]:
|
||||
"""解析 GPX,返回 (坐标[(lat,lng)], 名称, 类型)。优先 trkseg,其次 rte,最后 wpt。"""
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except ET.ParseError as exc:
|
||||
raise protocol.AppError("GPX 文件解析失败,请确认文件格式", "bad_gpx") from exc
|
||||
tag = _strip_ns(root.tag)
|
||||
if tag != "gpx":
|
||||
raise protocol.AppError("这不是一个 GPX 文件", "bad_gpx")
|
||||
|
||||
name = ""
|
||||
for child in root:
|
||||
if _strip_ns(child.tag) == "metadata":
|
||||
for item in child:
|
||||
if _strip_ns(item.tag) == "name" and item.text:
|
||||
name = item.text.strip()
|
||||
break
|
||||
if name:
|
||||
break
|
||||
|
||||
coords: list[tuple[float, float]] = []
|
||||
for child in root:
|
||||
ctag = _strip_ns(child.tag)
|
||||
if ctag == "trk":
|
||||
if not name:
|
||||
for item in child:
|
||||
if _strip_ns(item.tag) == "name" and item.text:
|
||||
name = item.text.strip()
|
||||
break
|
||||
for seg in child:
|
||||
if _strip_ns(seg.tag) != "trkseg":
|
||||
continue
|
||||
for point in seg:
|
||||
parsed = _parse_point(point)
|
||||
if parsed:
|
||||
coords.append(parsed)
|
||||
elif ctag == "rte":
|
||||
for point in child:
|
||||
if _strip_ns(point.tag) == "rtept":
|
||||
parsed = _parse_point(point)
|
||||
if parsed:
|
||||
coords.append(parsed)
|
||||
if not coords:
|
||||
for child in root:
|
||||
if _strip_ns(child.tag) == "wpt":
|
||||
parsed = _parse_point(child)
|
||||
if parsed:
|
||||
coords.append(parsed)
|
||||
if not coords:
|
||||
raise protocol.AppError("GPX 里没有可用的轨迹点", "bad_gpx")
|
||||
# 不做抽稀:GPX 是需要保形重放的轨迹,丢点会导致导入后形状变化(仅在极端点数时等间隔抽稀)
|
||||
coords = _thin_if_huge(coords)
|
||||
return coords, (name or "导入的 GPX 轨迹"), "gpx"
|
||||
|
||||
|
||||
def _thin_if_huge(coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""仅在点数极多时按等间隔抽稀,保证不丢形状(正常大小的 GPX 原样返回)。"""
|
||||
if len(coords) <= MAX_PATH_POINTS:
|
||||
return coords
|
||||
step = len(coords) / float(MAX_PATH_POINTS)
|
||||
out: list[tuple[float, float]] = []
|
||||
index = 0.0
|
||||
while int(index) < len(coords):
|
||||
out.append(coords[int(index)])
|
||||
index += step
|
||||
if out[-1] != coords[-1]:
|
||||
out.append(coords[-1])
|
||||
return out
|
||||
|
||||
|
||||
def _parse_point(node: ET.Element) -> tuple[float, float] | None:
|
||||
lat = node.get("lat")
|
||||
lng = node.get("lon")
|
||||
if lat is None or lng is None:
|
||||
return None
|
||||
try:
|
||||
flat, flng = protocol.validate_latlng(float(lat), float(lng))
|
||||
except (protocol.AppError, TypeError, ValueError):
|
||||
return None
|
||||
return flat, flng
|
||||
|
||||
|
||||
def _strip_ns(tag: str) -> str:
|
||||
return tag.split("}", 1)[1] if "}" in tag else tag
|
||||
|
||||
|
||||
def _avg_speed(route: Route) -> float:
|
||||
if not route.speeds:
|
||||
return 1.0
|
||||
return max(0.2, sum(route.speeds) / len(route.speeds))
|
||||
|
||||
|
||||
def _fmt_duration(seconds: float) -> str:
|
||||
total = int(max(0, round(seconds)))
|
||||
hours, rem = divmod(total, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
if hours:
|
||||
return f"{hours} 小时 {minutes} 分"
|
||||
if minutes:
|
||||
return f"{minutes} 分 {secs} 秒"
|
||||
return f"{secs} 秒"
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
import datetime
|
||||
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _iso_offset(seconds: float) -> str:
|
||||
import datetime
|
||||
|
||||
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=float(seconds))
|
||||
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _indent(elem: ET.Element, level: int = 0) -> None:
|
||||
pad = "\n" + " " * level
|
||||
if len(elem):
|
||||
if not elem.text or not elem.text.strip():
|
||||
elem.text = pad + " "
|
||||
for child in elem:
|
||||
_indent(child, level + 1)
|
||||
if not child.tail or not child.tail.strip():
|
||||
child.tail = pad
|
||||
if level and (not elem.tail or not elem.tail.strip()):
|
||||
elem.tail = pad
|
||||
|
||||
|
||||
def haversine_total(path: list[tuple[float, float]]) -> float:
|
||||
if len(path) < 2:
|
||||
return 0.0
|
||||
return geoutils.cumulative_lengths(path)[-1]
|
||||
|
||||
|
||||
def sanitize_route_payload(payload: Any) -> Route:
|
||||
"""前端 play.start 直接传 route 时,从 payload 还原 Route 对象。"""
|
||||
if not isinstance(payload, dict):
|
||||
raise protocol.AppError("route 必须是对象", "bad_route")
|
||||
path_raw = payload.get("path") or []
|
||||
path = parse_waypoints(path_raw, limit=MAX_REQUEST_POINTS)
|
||||
if len(path) < 1:
|
||||
waypoints = parse_waypoints(payload.get("waypoints") or [])
|
||||
if len(waypoints) < 1:
|
||||
raise protocol.AppError("轨迹为空,请先添加途经点或绘制轨迹", "empty_route")
|
||||
path = waypoints
|
||||
speeds_raw = payload.get("speeds") or []
|
||||
mode = protocol.validate_mode(payload.get("mode"))
|
||||
route = Route(
|
||||
name=str(payload.get("name") or "未命名轨迹")[:80],
|
||||
mode=mode,
|
||||
waypoints=parse_waypoints(payload.get("waypoints") or []) or [path[0], path[-1]],
|
||||
path=path,
|
||||
engine=str(payload.get("engine") or "manual")[:16],
|
||||
loop=bool(payload.get("loop")),
|
||||
speed_scale=protocol.validate_speed_scale(payload.get("speed_scale") or 1.0),
|
||||
)
|
||||
if len(speeds_raw) == len(path):
|
||||
base = protocol.MODE_DEFAULTS.get(mode, 13.9)
|
||||
speeds: list[float] = []
|
||||
for value in speeds_raw:
|
||||
# 非有限数(NaN/Infinity)或越界值一律回退到基准速度,避免污染整条链路
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
speeds.append(base)
|
||||
continue
|
||||
if not math.isfinite(number) or not (0.05 <= number <= 400.0):
|
||||
speeds.append(base)
|
||||
else:
|
||||
speeds.append(number)
|
||||
route.speeds = speeds
|
||||
else:
|
||||
route.speeds = compute_speeds(path, mode, protocol.MODE_DEFAULTS.get(mode, 13.9))
|
||||
route._acc = geoutils.cumulative_lengths(path)
|
||||
return route
|
||||
|
||||
|
||||
def default_route_json(engine: RouteEngine, waypoints: list[Any], mode: str = "driving") -> dict[str, Any]:
|
||||
return engine.build(waypoints, mode=mode).to_json()
|
||||
+1352
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,323 @@
|
||||
"""地图瓦片本地缓存代理。
|
||||
|
||||
- 前端只请求 /api/tiles/{source}/{z}/{x}/{y}.png,由本模块回源到瓦片服务商并落盘缓存。
|
||||
- 缓存目录可配置(默认 ~/.ios-location-tiles),加载过的区域在断网时仍可显示。
|
||||
- 无第三方依赖:urllib 回源 + 严格锁保证并发安全。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import protocol
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
)
|
||||
_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
# 1x1 透明 PNG,回源彻底失败时返回,避免前端整块报错
|
||||
PLACEHOLDER_PNG = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d4944415478da63fcffff3f0300050001a5f645ea0000000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TileResult:
|
||||
body: bytes
|
||||
content_type: str
|
||||
status: int
|
||||
source: str # "cache" | "network" | "placeholder" | "error"
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == 200
|
||||
|
||||
|
||||
class TileCache:
|
||||
def __init__(self, cache_dir: str | os.PathLike[str], max_mb: int = 1024, timeout: float = 12.0) -> None:
|
||||
self.dir = Path(cache_dir).expanduser()
|
||||
self.max_bytes = max(64, int(max_mb)) * 1024 * 1024
|
||||
self.timeout = timeout
|
||||
self._lock = threading.RLock()
|
||||
self._last_sweep = 0.0
|
||||
self._inflight: dict[str, threading.Lock] = {}
|
||||
self.stats = {"hit": 0, "miss": 0, "network": 0, "failed": 0, "placeholder": 0}
|
||||
try:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ---------------- 内部 ----------------
|
||||
def _path_for(self, source: str, z: int, x: int, y: int, ext: str = ".png") -> Path:
|
||||
return self.dir / source / str(z) / str(x) / f"{y}{ext}"
|
||||
|
||||
def _find_cached(self, source: str, z: int, x: int, y: int) -> Path | None:
|
||||
for ext in (".png", ".jpg", ".webp"):
|
||||
candidate = self._path_for(source, z, x, y, ext)
|
||||
if candidate.exists() and candidate.stat().st_size > 0:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _write(self, source: str, z: int, x: int, y: int, ext: str, body: bytes) -> Path:
|
||||
path = self._path_for(source, z, x, y, ext)
|
||||
with self._lock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_bytes(body)
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
def _sweep(self) -> None:
|
||||
"""超过容量上限时按最后访问时间淘汰旧瓦片。"""
|
||||
if self.max_bytes <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
if now - self._last_sweep < 60:
|
||||
return
|
||||
self._last_sweep = now
|
||||
entries: list[tuple[float, int, Path]] = []
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(self.dir):
|
||||
for name in files:
|
||||
if not name.endswith((".png", ".jpg", ".webp")):
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total += st.st_size
|
||||
entries.append((st.st_atime, st.st_size, path))
|
||||
if total <= self.max_bytes:
|
||||
return
|
||||
entries.sort(key=lambda item: item[0])
|
||||
target = total - int(self.max_bytes * 0.85)
|
||||
freed = 0
|
||||
for _atime, size, path in entries:
|
||||
if freed >= target:
|
||||
break
|
||||
try:
|
||||
path.unlink()
|
||||
freed += size
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def _lock_for(self, key: str) -> threading.Lock:
|
||||
with self._lock:
|
||||
lock = self._inflight.get(key)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._inflight[key] = lock
|
||||
return lock
|
||||
|
||||
# ---------------- 对外 ----------------
|
||||
def get_tile(self, source_key: str, source: dict[str, Any], z: int, x: int, y: int) -> TileResult:
|
||||
cached = self._find_cached(source_key, z, x, y)
|
||||
if cached is not None:
|
||||
try:
|
||||
blob = cached.read_bytes()
|
||||
except OSError:
|
||||
blob = b""
|
||||
if blob:
|
||||
if _is_blank_tile(blob):
|
||||
# 之前可能把空瓦片缓存下来了:删掉并重新回源
|
||||
try:
|
||||
cached.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
self.stats["hit"] += 1
|
||||
try:
|
||||
os.utime(cached, None)
|
||||
except OSError:
|
||||
pass
|
||||
return TileResult(blob, _mime_for(cached.suffix), 200, "cache")
|
||||
|
||||
key = f"{source_key}/{z}/{x}/{y}"
|
||||
with self._lock_for(key):
|
||||
cached = self._find_cached(source_key, z, x, y)
|
||||
if cached is not None:
|
||||
try:
|
||||
blob = cached.read_bytes()
|
||||
except OSError:
|
||||
blob = b""
|
||||
if blob and not _is_blank_tile(blob):
|
||||
self.stats["hit"] += 1
|
||||
return TileResult(blob, _mime_for(cached.suffix), 200, "cache")
|
||||
|
||||
self.stats["miss"] += 1
|
||||
url = build_url(source, z, x, y)
|
||||
if not url:
|
||||
self.stats["failed"] += 1
|
||||
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
|
||||
try:
|
||||
body, mime = self._fetch(url)
|
||||
except Exception:
|
||||
self.stats["failed"] += 1
|
||||
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
|
||||
if not body or _is_blank_tile(body):
|
||||
# 服务商返回空图(多为瓦片不存在或参数不对):不写缓存,交给前端显示占位
|
||||
self.stats["failed"] += 1
|
||||
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
|
||||
ext = _EXT_BY_MIME.get(mime, ".png")
|
||||
self._write(source_key, z, x, y, ext, body)
|
||||
self.stats["network"] += 1
|
||||
self._sweep()
|
||||
return TileResult(body, mime, 200, "network")
|
||||
|
||||
def _fetch(self, url: str) -> tuple[bytes, str]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": _UA,
|
||||
"Accept": "image/avif,image/webp,image/png,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Referer": _referer_for(url),
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
body = response.read()
|
||||
mime = (response.headers.get("Content-Type") or "image/png").split(";")[0].strip().lower()
|
||||
if mime not in _EXT_BY_MIME and not body.startswith(b"\xff\xd8") and not body.startswith(b"\x89PNG"):
|
||||
raise ValueError("返回的不是图片")
|
||||
return body, mime
|
||||
|
||||
def clear(self) -> int:
|
||||
"""清空缓存,返回删除的瓦片数。"""
|
||||
removed = 0
|
||||
with self._lock:
|
||||
for root, _dirs, files in os.walk(self.dir, topdown=False):
|
||||
for name in files:
|
||||
try:
|
||||
(Path(root) / name).unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
def size_bytes(self) -> int:
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(self.dir):
|
||||
for name in files:
|
||||
try:
|
||||
total += (Path(root) / name).stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _mime_for(suffix: str) -> str:
|
||||
return {".jpg": "image/jpeg", ".webp": "image/webp"}.get(suffix, "image/png")
|
||||
|
||||
|
||||
def _is_blank_tile(body: bytes) -> bool:
|
||||
"""识别「全透明 / 纯色」的空瓦片。
|
||||
|
||||
某些瓦片服务在参数或图层不对时会返回 200 + 256x256 的全透明 PNG(体积很小),
|
||||
这类图直接给前端只会显示白块,因此这里当作失败处理。
|
||||
"""
|
||||
if not body:
|
||||
return True
|
||||
if not body.startswith(b"\x89PNG"):
|
||||
return False # JPEG 等格式不在此判断
|
||||
if len(body) > 6000:
|
||||
return False # 体积正常的 PNG 基本都有内容
|
||||
try:
|
||||
import zlib
|
||||
|
||||
if body[24] != 8: # bit depth
|
||||
return False
|
||||
color_type = body[25]
|
||||
pos = 8
|
||||
idat = bytearray()
|
||||
while pos + 8 <= len(body):
|
||||
length = int.from_bytes(body[pos:pos + 4], "big")
|
||||
chunk_type = body[pos + 4:pos + 8]
|
||||
data = body[pos + 8:pos + 8 + length]
|
||||
if chunk_type == b"IDAT":
|
||||
idat.extend(data)
|
||||
elif chunk_type == b"IEND":
|
||||
break
|
||||
pos += 12 + length
|
||||
if not idat:
|
||||
return True
|
||||
raw = zlib.decompress(bytes(idat))
|
||||
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color_type, 1)
|
||||
bpp = channels
|
||||
stride = 256 * bpp
|
||||
# 这里只做粗略判断:全 0(透明/黑)或单一字节值的图像视为空
|
||||
first = None
|
||||
for row in range(min(8, 256)):
|
||||
start = row * (stride + 1) + 1
|
||||
chunk = raw[start:start + stride]
|
||||
if not chunk:
|
||||
break
|
||||
if first is None:
|
||||
first = bytes(chunk)
|
||||
elif bytes(chunk) != first:
|
||||
return False
|
||||
if first is None:
|
||||
return True
|
||||
if color_type in (4, 6):
|
||||
# 带 alpha 通道:全部 alpha 为 0 视为全透明
|
||||
alpha_index = bpp - 1
|
||||
return all(first[i] == 0 for i in range(alpha_index, len(first), bpp))
|
||||
return len(set(first)) <= 1
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _referer_for(url: str) -> str:
|
||||
try:
|
||||
parts = url.split("/")
|
||||
return f"{parts[0]}//{parts[2]}/"
|
||||
except Exception:
|
||||
return "https://www.amap.com/"
|
||||
|
||||
|
||||
def build_url(source: dict[str, Any], z: int, x: int, y: int) -> str:
|
||||
"""把 {z}/{x}/{y}/{s} 模板替换成真实 URL。"""
|
||||
template = str(source.get("url") or "")
|
||||
if not template:
|
||||
return ""
|
||||
subs = source.get("subdomains") or []
|
||||
if "{s}" in template:
|
||||
if subs:
|
||||
sub = subs[(x + y) % len(subs)]
|
||||
else:
|
||||
sub = source.get("subdomain_default", "1")
|
||||
template = template.replace("{s}", str(sub))
|
||||
max_zoom = int(source.get("max_zoom") or 19)
|
||||
z = min(z, max_zoom)
|
||||
try:
|
||||
return template.format(z=z, x=x, y=y, s="")
|
||||
except (KeyError, IndexError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def validate_tile_request(z: Any, x: Any, y: Any) -> tuple[int, int, int]:
|
||||
try:
|
||||
iz, ix, iy = int(z), int(x), int(y)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise protocol.AppError("瓦片坐标必须是整数", "bad_tile") from exc
|
||||
if not (0 <= iz <= 22):
|
||||
raise protocol.AppError("缩放级别超出范围", "bad_tile")
|
||||
limit = 1 << iz
|
||||
if not (0 <= ix < limit and 0 <= iy < limit):
|
||||
raise protocol.AppError("瓦片坐标超出该缩放级别范围", "bad_tile")
|
||||
return iz, ix, iy
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
"""iOS 17+ 的 RSD 隧道管理。
|
||||
|
||||
背景(见 docs/DEVICE_NOTES.md 的取证结论):
|
||||
- iOS 17 及以上,DVT 服务不再走 usbmux 代理,必须先在 Mac 与设备之间建立 RSD 隧道。
|
||||
- 经典隧道(kernel utun)需要 root;`pymobiledevice3 remote start-tunnel` 在 macOS 上支持
|
||||
`--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来
|
||||
(--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。
|
||||
- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端
|
||||
(即:本工具运行时应关闭爱思助手等同类工具)。
|
||||
- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import protocol
|
||||
|
||||
POLL_INTERVAL = 0.25
|
||||
START_TIMEOUT = 75.0
|
||||
_HINT_NATIVE_FAILED = (
|
||||
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
|
||||
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
|
||||
"④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh"
|
||||
)
|
||||
_HINT_NATIVE_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
|
||||
|
||||
|
||||
class TunnelManager:
|
||||
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port)。"""
|
||||
|
||||
def __init__(self, venv_python: str | None = None, log=None, on_change=None) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.state = "idle" # idle|starting|connected|error|unsupported
|
||||
self.message = ""
|
||||
self.host: str | None = None
|
||||
self.port: int | None = None
|
||||
self.udid: str | None = None
|
||||
self.mode: str | None = None # native|sudo|manual
|
||||
self._process: subprocess.Popen[str] | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._needs_root = False
|
||||
self._log = log
|
||||
self._on_change = on_change
|
||||
self.venv_python = venv_python or _detect_python()
|
||||
|
||||
# ---------------- 状态 ----------------
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"state": self.state,
|
||||
"message": self.message or _default_message(self.state),
|
||||
"rsd": f"{self.host} {self.port}" if self.host and self.port else None,
|
||||
"mode": self.mode,
|
||||
"udid": self.udid,
|
||||
}
|
||||
|
||||
def _emit(self, level: str, message: str) -> None:
|
||||
if self._log:
|
||||
try:
|
||||
self._log(level, message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_state(self, state: str, message: str = "", **fields: Any) -> None:
|
||||
with self._lock:
|
||||
self.state = state
|
||||
self.message = message
|
||||
for key, value in fields.items():
|
||||
setattr(self, key, value)
|
||||
if self._on_change:
|
||||
try:
|
||||
self._on_change()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
with self._lock:
|
||||
return bool(self.host and self.port) and self.state == "connected"
|
||||
|
||||
def address(self) -> tuple[str, int] | None:
|
||||
with self._lock:
|
||||
if self.host and self.port:
|
||||
return self.host, self.port
|
||||
return None
|
||||
|
||||
# ---------------- 启动 ----------------
|
||||
def ensure(self, udid: str | None, allow_sudo: bool = False) -> dict[str, Any]:
|
||||
"""保证隧道可用。已连接且 UDID 未变时直接复用。"""
|
||||
with self._lock:
|
||||
if self.ready and (udid is None or self.udid in (None, udid)):
|
||||
return self.snapshot()
|
||||
self.stop()
|
||||
return self.start(udid=udid, allow_sudo=allow_sudo)
|
||||
|
||||
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]:
|
||||
# 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉,
|
||||
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
|
||||
if not allow_sudo:
|
||||
self._kill_stale_tunnels()
|
||||
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, host=None, port=None)
|
||||
command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo)
|
||||
if command is None:
|
||||
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
|
||||
return self.snapshot()
|
||||
|
||||
env = dict(os.environ)
|
||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||
env.setdefault("PYTHONPATH", str(_project_root()))
|
||||
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
|
||||
return self.snapshot()
|
||||
|
||||
with self._lock:
|
||||
self._process = process
|
||||
self.mode = mode
|
||||
self._needs_root = mode == "sudo"
|
||||
if mode != "sudo":
|
||||
_write_pidfile([process.pid])
|
||||
self._reader = threading.Thread(target=self._read_output, args=(process,), name="tunnel-reader", daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
deadline = time.time() + START_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
with self._lock:
|
||||
if self.ready:
|
||||
return self.snapshot()
|
||||
if self.state == "error":
|
||||
return self.snapshot()
|
||||
if process.poll() is not None and not self.ready:
|
||||
break
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
if not self.ready:
|
||||
message = _HINT_NATIVE_FAILED_SHORT
|
||||
if mode == "sudo":
|
||||
message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错"
|
||||
self.stop()
|
||||
self._set_state("error", message, mode=mode)
|
||||
return self.snapshot()
|
||||
|
||||
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
|
||||
"""拼出"建隧道"命令。
|
||||
|
||||
- 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X`
|
||||
—— 这条路径不依赖机器上有 Python/pymobiledevice3;
|
||||
- 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。
|
||||
"""
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
base = [sys.executable, "tunnel-helper"]
|
||||
else:
|
||||
cli = self._cli_command()
|
||||
if cli is None:
|
||||
return None, "native"
|
||||
base = [*cli, "remote", "start-tunnel", "--native", "--script-mode"]
|
||||
command = list(base)
|
||||
if udid:
|
||||
command += ["--udid", udid]
|
||||
if not allow_sudo:
|
||||
return command, "native"
|
||||
inner = " ".join(_shell_quote(part) for part in command)
|
||||
script = f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" with administrator privileges'
|
||||
return ["osascript", "-e", script], "sudo"
|
||||
|
||||
def _kill_stale_tunnels(self) -> None:
|
||||
"""清理**本工具自己**遗留在后台的 start-tunnel 进程。
|
||||
|
||||
iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。
|
||||
为了避免误杀用户在终端手动起的隧道或其它工具,这里只处理本项目上次运行时
|
||||
记录在 pid 文件里的进程(会先用 ps 校验命令行确实是 start-tunnel)。
|
||||
"""
|
||||
pids = _read_pidfile()
|
||||
if not pids:
|
||||
return
|
||||
alive: list[int] = []
|
||||
for pid in pids:
|
||||
command = _command_of(pid)
|
||||
if command and "start-tunnel" in command:
|
||||
alive.append(pid)
|
||||
_write_pidfile([])
|
||||
if not alive:
|
||||
return
|
||||
self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…")
|
||||
for pid in alive:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
for _ in range(10):
|
||||
time.sleep(0.3)
|
||||
if _command_of(pid) is None:
|
||||
break
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cli_command(self) -> list[str] | None:
|
||||
"""返回"建隧道"命令的前缀。
|
||||
|
||||
- 打包成 .app 独立运行时(sys.frozen):用本可执行文件 + `tunnel-helper` 子命令
|
||||
(递归调用自己),因此**不需要机器上装 Python 或 pymobiledevice3**;
|
||||
- 源码运行时:优先 venv 里的 `python -m pymobiledevice3`,其次 PATH 里的可执行文件。
|
||||
"""
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, "tunnel-helper"]
|
||||
candidate = _detect_python()
|
||||
if candidate:
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[candidate, "-c", "import pymobiledevice3, sys; print(pymobiledevice3.__version__ if hasattr(pymobiledevice3,'__version__') else 'ok')"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=25,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return [candidate, "-m", "pymobiledevice3"]
|
||||
except Exception:
|
||||
pass
|
||||
direct = shutil.which("pymobiledevice3")
|
||||
if direct:
|
||||
return [direct]
|
||||
return None
|
||||
|
||||
# ---------------- 输出解析 ----------------
|
||||
def _read_output(self, process: subprocess.Popen[str]) -> None:
|
||||
pattern = re.compile(r"(?P<host>[0-9a-fA-F:.]{3,})\s+(?P<port>\d{2,5})")
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
self._emit("info", f"[tunnel] {text}")
|
||||
if self.ready:
|
||||
continue
|
||||
found = _parse_rsd_line(text, pattern)
|
||||
if found:
|
||||
host, port = found
|
||||
self._set_state(
|
||||
"connected",
|
||||
f"隧道已建立({self.mode or 'native'}):{host} 端口 {port}",
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
with self._lock:
|
||||
code = process.poll()
|
||||
if code is not None and not self.ready:
|
||||
self._set_state("error", f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}")
|
||||
|
||||
# ---------------- 手动 / 释放 ----------------
|
||||
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict[str, Any]:
|
||||
host = (host or "").strip()
|
||||
try:
|
||||
iport = int(port)
|
||||
except (TypeError, ValueError):
|
||||
raise protocol.AppError("RSD 端口必须是数字", "bad_rsd")
|
||||
if not host:
|
||||
raise protocol.AppError("RSD 地址不能为空", "bad_rsd")
|
||||
self.stop()
|
||||
self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}", host=host, port=iport, mode="manual", udid=udid)
|
||||
return self.snapshot()
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=4)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self.host = None
|
||||
self.port = None
|
||||
self.mode = None
|
||||
if self.state in ("connected", "starting", "error", "unsupported"):
|
||||
self.state = "idle"
|
||||
self.message = _default_message("idle")
|
||||
_write_pidfile([])
|
||||
|
||||
|
||||
def _parse_rsd_line(text: str, pattern: re.Pattern[str]) -> tuple[str, int] | None:
|
||||
if text.startswith("--rsd "):
|
||||
parts = text.split()
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
return parts[1], int(parts[2])
|
||||
except ValueError:
|
||||
return None
|
||||
if "RSD Port" in text:
|
||||
return None
|
||||
match = pattern.fullmatch(text) or pattern.match(text)
|
||||
if not match:
|
||||
return None
|
||||
host = match.group("host")
|
||||
if ":" not in host and "." not in host:
|
||||
return None
|
||||
try:
|
||||
port = int(match.group("port"))
|
||||
except ValueError:
|
||||
return None
|
||||
if not (1 <= port <= 65535):
|
||||
return None
|
||||
return host, port
|
||||
|
||||
|
||||
def _default_message(state: str) -> str:
|
||||
return {
|
||||
"idle": "隧道未建立(iOS 17 及以上需要)",
|
||||
"starting": "正在建立隧道…",
|
||||
"connected": "隧道已连接",
|
||||
"error": _HINT_NATIVE_FAILED,
|
||||
"unsupported": "当前环境不支持自动建隧道",
|
||||
}.get(state, "")
|
||||
|
||||
|
||||
def _mask(command: list[str]) -> list[str]:
|
||||
return command
|
||||
|
||||
|
||||
def _pidfile_path() -> str:
|
||||
return os.path.join(_project_root(), ".tunnel.pids")
|
||||
|
||||
|
||||
def _read_pidfile() -> list[int]:
|
||||
try:
|
||||
with open(_pidfile_path(), "r", encoding="utf-8") as handle:
|
||||
return [int(line.strip()) for line in handle if line.strip().isdigit()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _write_pidfile(pids: list[int]) -> None:
|
||||
try:
|
||||
path = _pidfile_path()
|
||||
if not pids:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(str(pid) for pid in pids))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _command_of(pid: int) -> str | None:
|
||||
"""返回进程命令行;进程不存在返回 None。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
text = (result.stdout or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _shell_quote(text: str) -> str:
|
||||
return "'" + text.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
"""数据目录根:源码运行用项目目录,打包后只读,改用用户可写目录。"""
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
return _writable_data_dir()
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _writable_data_dir() -> str:
|
||||
"""打包运行时可写的数据目录(~/Library/Application Support/...)。"""
|
||||
base = os.path.expanduser("~/Library/Application Support/iOSLocationTrack")
|
||||
try:
|
||||
os.makedirs(base, exist_ok=True)
|
||||
except OSError:
|
||||
base = os.path.expanduser("~")
|
||||
return base
|
||||
|
||||
|
||||
def _detect_python() -> str | None:
|
||||
"""返回项目 venv 的 python,找不到就退回当前解释器。"""
|
||||
import sys
|
||||
|
||||
root = _project_root()
|
||||
for candidate in (
|
||||
os.path.join(root, ".venv", "bin", "python"),
|
||||
os.path.join(root, ".venv", "bin", "python3"),
|
||||
):
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return sys.executable
|
||||
|
||||
|
||||
def tunnel_supported() -> bool:
|
||||
import platform
|
||||
|
||||
return platform.system() == "Darwin"
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""极简 WebSocket 服务端实现(RFC 6455):零第三方依赖。
|
||||
|
||||
设计:
|
||||
- ThreadingHTTPServer 的 handler 检测到 Upgrade: websocket 后调用 serve(),
|
||||
该函数劫持 socket 并阻塞在当前线程里跑消息循环。
|
||||
- 发送可能来自任意线程(HTTP 线程、播放引擎线程),因此内部用锁 + deque 保证串行写。
|
||||
- 服务端只推送文本帧,不接收分片消息(浏览器不会对短 JSON 做分片)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Callable
|
||||
|
||||
from . import protocol
|
||||
|
||||
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
OP_CONT = 0x0
|
||||
OP_TEXT = 0x1
|
||||
OP_BIN = 0x2
|
||||
OP_CLOSE = 0x8
|
||||
OP_PING = 0x9
|
||||
OP_PONG = 0xA
|
||||
|
||||
MAX_FRAME = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def is_websocket_upgrade(headers: Any) -> bool:
|
||||
upgrade = (headers.get("Upgrade") or "").strip().lower()
|
||||
connection = (headers.get("Connection") or "").lower()
|
||||
return upgrade == "websocket" and "upgrade" in connection
|
||||
|
||||
|
||||
def accept_key(client_key: str) -> str:
|
||||
digest = hashlib.sha1((client_key + GUID).encode("ascii")).digest()
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
|
||||
class WSConnection:
|
||||
"""一条已升级的连接。线程安全的 send_json。"""
|
||||
|
||||
def __init__(self, sock: socket.socket, addr: tuple[str, int], require_masked: bool = True) -> None:
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.require_masked = require_masked
|
||||
self.id = f"ws-{int(time.time() * 1000) % 100000}-{os.urandom(2).hex()}"
|
||||
self._send_lock = threading.Lock()
|
||||
self._closed = threading.Event()
|
||||
self._buf = b""
|
||||
self._frag_op: int | None = None
|
||||
self._frag_data = bytearray()
|
||||
self.bytes_sent = 0
|
||||
self.bytes_recv = 0
|
||||
|
||||
# ---------------- 发送 ----------------
|
||||
def send_frame(self, opcode: int, payload: bytes = b"") -> None:
|
||||
if self._closed.is_set():
|
||||
return
|
||||
header = bytearray()
|
||||
header.append(0x80 | opcode)
|
||||
length = len(payload)
|
||||
if length < 126:
|
||||
header.append(length)
|
||||
elif length < 65536:
|
||||
header.append(126)
|
||||
header.extend(struct.pack("!H", length))
|
||||
else:
|
||||
header.append(127)
|
||||
header.extend(struct.pack("!Q", length))
|
||||
frame = bytes(header) + payload
|
||||
with self._send_lock:
|
||||
if self._closed.is_set():
|
||||
return
|
||||
try:
|
||||
self.sock.sendall(frame)
|
||||
self.bytes_sent += len(frame)
|
||||
except OSError:
|
||||
self._closed.set()
|
||||
|
||||
def send_text(self, text: str) -> None:
|
||||
self.send_frame(OP_TEXT, text.encode("utf-8"))
|
||||
|
||||
def send_json(self, obj: dict[str, Any]) -> None:
|
||||
self.send_text(protocol.dumps(obj))
|
||||
|
||||
def ping(self) -> None:
|
||||
self.send_frame(OP_PING)
|
||||
|
||||
def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
if self._closed.is_set():
|
||||
return
|
||||
payload = struct.pack("!H", code) + reason.encode("utf-8")
|
||||
try:
|
||||
self.send_frame(OP_CLOSE, payload)
|
||||
finally:
|
||||
self._closed.set()
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self._closed.is_set()
|
||||
|
||||
# ---------------- 接收 ----------------
|
||||
def _recv_exact(self, n: int) -> bytes | None:
|
||||
while len(self._buf) < n:
|
||||
try:
|
||||
chunk = self.sock.recv(65536)
|
||||
except (OSError, socket.timeout):
|
||||
return None
|
||||
if not chunk:
|
||||
return None
|
||||
self._buf += chunk
|
||||
self.bytes_recv += len(chunk)
|
||||
data, self._buf = self._buf[:n], self._buf[n:]
|
||||
return data
|
||||
|
||||
def _read_frame(self) -> tuple[int, bool, bytes] | None:
|
||||
head = self._recv_exact(2)
|
||||
if head is None:
|
||||
return None
|
||||
b1, b2 = head[0], head[1]
|
||||
fin = bool(b1 & 0x80)
|
||||
opcode = b1 & 0x0F
|
||||
masked = bool(b2 & 0x80)
|
||||
length = b2 & 0x7F
|
||||
if length == 126:
|
||||
ext = self._recv_exact(2)
|
||||
if ext is None:
|
||||
return None
|
||||
length = struct.unpack("!H", ext)[0]
|
||||
elif length == 127:
|
||||
ext = self._recv_exact(8)
|
||||
if ext is None:
|
||||
return None
|
||||
length = struct.unpack("!Q", ext)[0]
|
||||
if length > MAX_FRAME:
|
||||
self.close(1009, "frame too large")
|
||||
return None
|
||||
mask = b""
|
||||
if masked:
|
||||
mask = self._recv_exact(4) or b""
|
||||
if len(mask) != 4:
|
||||
return None
|
||||
elif self.require_masked:
|
||||
# RFC 6455:客户端发来的帧必须带掩码,否则属协议错误
|
||||
self.close(1002, "unmasked frame from client")
|
||||
return None
|
||||
payload = self._recv_exact(length) if length else b""
|
||||
if payload is None:
|
||||
return None
|
||||
if masked and payload:
|
||||
payload = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
|
||||
return opcode, fin, payload
|
||||
|
||||
def run(self, on_message: Callable[["WSConnection", dict[str, Any]], None],
|
||||
on_close: Callable[["WSConnection"], None] | None = None) -> None:
|
||||
"""消息循环,阻塞直到对端关闭或出错。"""
|
||||
try:
|
||||
self.sock.settimeout(None)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
while not self._closed.is_set():
|
||||
frame = self._read_frame()
|
||||
if frame is None:
|
||||
break
|
||||
opcode, fin, payload = frame
|
||||
if opcode == OP_CLOSE:
|
||||
self.send_frame(OP_CLOSE, payload[:2] if len(payload) >= 2 else b"")
|
||||
break
|
||||
if opcode == OP_PING:
|
||||
self.send_frame(OP_PONG, payload)
|
||||
continue
|
||||
if opcode == OP_PONG:
|
||||
continue
|
||||
if opcode in (OP_TEXT, OP_BIN):
|
||||
if fin:
|
||||
data = payload
|
||||
else:
|
||||
self._frag_op = opcode
|
||||
self._frag_data = bytearray(payload)
|
||||
continue
|
||||
elif opcode == OP_CONT:
|
||||
if self._frag_op is None:
|
||||
continue
|
||||
self._frag_data.extend(payload)
|
||||
if len(self._frag_data) > MAX_FRAME:
|
||||
self.close(1009, "fragmented message too large")
|
||||
return None
|
||||
if not fin:
|
||||
continue
|
||||
data = bytes(self._frag_data)
|
||||
self._frag_op = None
|
||||
self._frag_data = bytearray()
|
||||
else:
|
||||
continue
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
msg = protocol.loads(data)
|
||||
except Exception:
|
||||
self.send_json({"type": "error", "error": "消息不是合法 JSON", "code": "bad_json"})
|
||||
continue
|
||||
if isinstance(msg, dict):
|
||||
on_message(self, msg)
|
||||
finally:
|
||||
self._closed.set()
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if on_close is not None:
|
||||
try:
|
||||
on_close(self)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def handshake(sock: socket.socket, headers: Any) -> WSConnection | None:
|
||||
"""完成 HTTP → WebSocket 升级。成功返回 WSConnection。
|
||||
|
||||
注意:只有当客户端请求了 Sec-WebSocket-Protocol 时才能回该头(RFC 6455)。
|
||||
否则浏览器会因为"服务端选择了未请求的子协议"而直接判定握手失败。
|
||||
"""
|
||||
key = headers.get("Sec-WebSocket-Key")
|
||||
if not key:
|
||||
try:
|
||||
sock.sendall(
|
||||
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
version = (headers.get("Sec-WebSocket-Version") or "").strip()
|
||||
if version and version != "13":
|
||||
# RFC 6455:只支持版本 13,其它版本必须明确拒绝,不能装作握手成功
|
||||
try:
|
||||
sock.sendall(
|
||||
b"HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\n"
|
||||
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
lines = [
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
f"Sec-WebSocket-Accept: {accept_key(key.strip())}",
|
||||
]
|
||||
requested = headers.get("Sec-WebSocket-Protocol")
|
||||
if requested:
|
||||
offered = [item.strip() for item in requested.split(",") if item.strip()]
|
||||
# 只回客户端提供的第一个协议,绝不回它没要求过的值
|
||||
if offered:
|
||||
lines.append(f"Sec-WebSocket-Protocol: {offered[0]}")
|
||||
response = "\r\n".join(lines) + "\r\n\r\n"
|
||||
try:
|
||||
sock.sendall(response.encode("ascii"))
|
||||
except OSError:
|
||||
return None
|
||||
addr = sock.getpeername() if hasattr(sock, "getpeername") else ("?", 0)
|
||||
if not isinstance(addr, tuple):
|
||||
addr = ("?", 0)
|
||||
return WSConnection(sock, (str(addr[0]), int(addr[1])))
|
||||
|
||||
|
||||
class Hub:
|
||||
"""连接注册表 + 广播。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._conns: deque[WSConnection] = deque()
|
||||
|
||||
def add(self, conn: WSConnection) -> None:
|
||||
with self._lock:
|
||||
self._conns.append(conn)
|
||||
|
||||
def remove(self, conn: WSConnection) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._conns.remove(conn)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def snapshot(self) -> list[WSConnection]:
|
||||
with self._lock:
|
||||
return [c for c in self._conns if not c.closed]
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.snapshot())
|
||||
|
||||
def broadcast(self, obj: dict[str, Any]) -> int:
|
||||
payload = protocol.dumps(obj)
|
||||
sent = 0
|
||||
for conn in self.snapshot():
|
||||
try:
|
||||
conn.send_text(payload)
|
||||
sent += 1
|
||||
except Exception:
|
||||
self.remove(conn)
|
||||
return sent
|
||||
|
||||
def close_all(self) -> None:
|
||||
for conn in self.snapshot():
|
||||
try:
|
||||
conn.close(1001, "server shutdown")
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self._conns.clear()
|
||||
Reference in new issue
Block a user