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:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+312
View File
@@ -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
View File
@@ -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()
+234
View File
@@ -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
+229
View File
@@ -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(),
}
+701
View File
@@ -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
View File
@@ -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
+687
View File
@@ -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"]
+160
View File
@@ -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
+576
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+323
View File
@@ -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
View File
@@ -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
View File
@@ -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()