Files
ios-location-track/tools/diagnose_run.py
T
jbjf6666 85e9a53750 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 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

232 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""校园跑类 App 定位诊断:检查我们下发给手机的定位点是否"对 App 有效"。
运动世界校园这类 App 不显示里程,常见原因不在"定位有没有送到",而在
"送到的点是否满足 App 的过滤条件"。本脚本按跑步 App 的典型过滤规则体检:
1. **速度是否合理**:配速过高(如用了驾车/飞行模式)会被判异常而不计里程;
2. **每帧位移是否够大**:位移太小会被"静止过滤"掉;
3. **下发频率**:运动类 App 通常按 1Hz 采样算里程,低于 1Hz 会丢点;
4. **坐标精度**:6 位小数(约 0.1 米)足够;
5. **轨迹是否在指定区域内**(校园跑要求在校内有效区域,这一点需要你提供学校坐标)。
用法:
./.venv/bin/python tools/diagnose_run.py # 只做参数体检
./.venv/bin/python tools/diagnose_run.py --live 20 # 连真机跑 20 秒并统计真实下发
./.venv/bin/python tools/diagnose_run.py --live 20 --lat 30.2741 --lng 120.1551
"""
from __future__ import annotations
import argparse
import statistics
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import geoutils, server as server_mod # noqa: E402
# 跑步 App 的常见阈值(不同学校/版本略有差异,这里取保守值)
APP_MIN_SPEED = 0.4 # m/s,低于此速度通常判为"站着不动"
APP_MAX_SPEED_WALK = 4.0 # m/s,约 14.4 km/h;超过常被判"非跑步/作弊"
APP_MIN_PER_FRAME = 0.05 # 米,位移小于此值容易被静止过滤
APP_MAX_PER_FRAME = 300.0 # 米,位移过大(跳点)会被判异常
APP_MIN_HZ = 1.0 # App 侧通常按 ≥1Hz 采样
def banner(text: str) -> None:
print(f"\n\033[1m{text}\033[0m")
def check_parameters(hz: float, mode_speed: float, drift_m: float, jitter: float) -> int:
problems = []
banner("1) 参数体检(按下发频率与配速推算)")
per_frame = mode_speed / hz if hz else 0
print(f" 下发频率 : {hz:g} Hz → 每帧 {1000 / hz:.0f} ms" if hz else " 下发频率: 非法")
print(f" 基准速度 : {mode_speed:.2f} m/s({mode_speed * 3.6:.1f} km/h)")
print(f" 每帧位移 : {per_frame:.3f} 米")
print(f" 漂移半径 : {drift_m:g} 米 | 速度浮动: ±{jitter * 100:g}%")
if hz < APP_MIN_HZ:
problems.append(f"下发频率 {hz:g}Hz 低于 {APP_MIN_HZ:g}Hz,App 可能采样不到足够的点")
if mode_speed < APP_MIN_SPEED:
problems.append(f"速度 {mode_speed:.2f} m/s 过慢,可能被判为静止")
if mode_speed > APP_MAX_SPEED_WALK:
problems.append(
f"速度 {mode_speed:.2f} m/s({mode_speed * 3.6:.1f} km/h)对跑步来说过快,"
f"运动类 App 常因此判为异常并**不记录里程**(建议 ≤{APP_MAX_SPEED_WALK * 3.6:.0f} km/h)"
)
if per_frame < APP_MIN_PER_FRAME:
problems.append(f"每帧位移仅 {per_frame:.3f} 米,可能被静止过滤(建议提高下发频率或速度)")
if per_frame > APP_MAX_PER_FRAME:
problems.append(f"每帧位移 {per_frame:.0f} 米过大,会被判为跳点")
if drift_m > 15:
problems.append(f"漂移半径 {drift_m:g} 米偏大,可能触发精度过滤(建议 4~8 米)")
if problems:
print("\n \033[33m⚠ 发现问题:\033[0m")
for item in problems:
print(f" - {item}")
else:
print("\n \033[32m✓ 参数组合合理\033[0m")
return len(problems)
def check_area(lat: float | None, lng: float | None) -> None:
banner("2) 区域检查(校园跑的关键!)")
if lat is None or lng is None:
print(" 未提供学校坐标。运动世界校园只在**校内有效区域**内计里程,")
print(" 请用 --lat/--lng 指定你学校的位置,或直接在界面上用「快速定位到坐标」跳过去画轨迹。")
return
print(f" 目标坐标: {lat:.6f}, {lng:.6f}")
print(" ⚠ 请确认这个坐标落在你学校的有效跑步区域内(该 App 会校验)。")
print(" 获取方式:手机上打开地图 App,长按学校操场位置,即可看到经纬度。")
def live_test(seconds: float, hz: float, mode: str, lat: float | None, lng: float | None) -> int:
banner(f"3) 真机实测:连续下发 {seconds:.0f} 秒并统计")
port = 8810
app = server_mod.App(root=ROOT, mock=False, port=port)
devices = app.devices.refresh()
if not devices:
print(" \033[31m✗ 未发现设备\033[0m")
return 1
device = app.devices.get_selected()
print(f" 设备: {device.name} | iOS {device.ios_version} | {device.connection}")
from selftest import WSClient # 延迟导入,避免非真机场景的依赖
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
client = WSClient("127.0.0.1", port, timeout=180)
try:
client.send({"type": "hello", "id": "h1"})
client.wait_for(lambda m: m.get("id") == "h1", timeout=20)
client.send({"type": "device.connect", "id": "c1"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=180)
if not (ack and ack.get("ok", True)):
print(f" \033[31m✗ 连接设备失败:{ack}\033[0m")
return 1
print(" 设备已连接(DVT 会话就绪)")
if lat is None or lng is None:
print(" 未提供坐标,跳过实测(用 --lat/--lng 指定学校位置)")
return 0
# 以给定坐标为中心,造一条 1 公里左右的环形轨迹
d = 0.0045 # 约 500 米
waypoints = [
[lng - d, lat - d], [lng + d, lat - d],
[lng + d, lat + d], [lng - d, lat + d],
[lng - d, lat - d],
]
route = app.routes.build(waypoints, mode=mode, use_osrm=False)
print(f" 轨迹: {route.distance:.0f} 米({mode},基准 {route.speed_at_distance(0):.2f} m/s)")
client.send({"type": "play.start", "id": "s1", "route": route.to_json(),
"speed_scale": 1.0,
"tuning": {"drift_enabled": True, "drift_m": 4,
"speed_jitter_enabled": True, "speed_jitter": 0.08}})
started = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=30)
if not (started and started.get("ok", True)):
print(f" \033[31m✗ 播放启动失败:{started}\033[0m")
return 1
print(f" 采集中…({seconds:.0f} 秒)")
time.sleep(seconds)
with client.lock:
samples = [m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"]
client.send({"type": "play.stop", "id": "st1"})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "st1", timeout=20)
if len(samples) < 3:
print(f" \033[31m✗ 只收到 {len(samples)} 个样本,定位可能没下发\033[0m")
return 1
gaps = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
speeds = [s["speed"] for s in samples if s.get("speed")]
real_hz = 1.0 / statistics.mean(gaps) if gaps else 0
total = sum(hops)
print(f"\n 实际下发频率: {real_hz:.2f} Hz({statistics.mean(gaps) * 1000:.0f} ms/点)")
print(f" 每帧位移 : 平均 {statistics.mean(hops):.3f} 米(最小 {min(hops):.3f})")
print(f" 累计位移 : {total:.1f} 米 / {seconds:.0f} 秒")
print(f" 上报速度 : 平均 {statistics.mean(speeds):.2f} m/s({statistics.mean(speeds) * 3.6:.1f} km/h)")
verdict = []
if real_hz < APP_MIN_HZ:
verdict.append(f"下发频率 {real_hz:.2f}Hz 偏低")
if min(hops) < APP_MIN_PER_FRAME:
verdict.append(f"存在 {min(hops):.3f} 米的极小位移(可能被静止过滤)")
if max(hops) > APP_MAX_PER_FRAME:
verdict.append(f"存在 {max(hops):.0f} 米的跳点")
if statistics.mean(speeds) > APP_MAX_SPEED_WALK:
verdict.append(f"速度 {statistics.mean(speeds) * 3.6:.1f} km/h 对跑步偏快")
print()
if verdict:
print(" \033[33m⚠ 下发的点可能被 App 过滤:\033[0m")
for item in verdict:
print(f" - {item}")
else:
print(" \033[32m✓ 下发的点在频率、位移、速度上都符合跑步 App 的接受范围\033[0m")
print("\n 请同时在手机上确认:")
print(" 1. 该 App 的定位权限是「使用 App 期间」或「始终」,并且「精确位置」已打开;")
print(" 2. 手机上打开系统地图,蓝点应沿着你画的轨迹移动(验证定位确实生效);")
print(" 3. 轨迹必须覆盖该 App 要求的必打点/必经点,否则里程不计入成绩。")
return 1 if verdict else 0
finally:
client.close()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--live", type=float, default=0, help="连真机实测多少秒(0=不实测)")
parser.add_argument("--mode", default="walking", choices=["walking", "cycling", "driving"])
parser.add_argument("--lat", type=float, default=None, help="学校纬度(WGS84)")
parser.add_argument("--lng", type=float, default=None, help="学校经度(WGS84)")
args = parser.parse_args()
print("\033[1m校园跑类 App 定位诊断\033[0m")
from core.config_store import ConfigStore
cfg = ConfigStore(ROOT / "config.json")
hz = float(cfg.get("send_hz") or 5)
mode_speed = float((cfg.get("mode_defaults") or {}).get(args.mode, 1.4))
drift_m = float(cfg.get("drift_m") or 0)
jitter = float(cfg.get("speed_jitter") or 0)
problems = check_parameters(hz, mode_speed, drift_m, jitter)
check_area(args.lat, args.lng)
live_problems = 0
if args.live > 0:
live_problems = live_test(args.live, hz, args.mode, args.lat, args.lng)
else:
banner("3) 真机实测")
print(" 跳过(加 --live 20 可连真机实测 20 秒)")
banner("结论")
if problems or live_problems:
print(" 参数或下发质量存在问题,见上面 ⚠ 项。")
return 1
print(" 定位下发本身没问题。如果 App 仍不显示里程,问题在 App 侧:")
print(" · 是否在**校内有效区域**内跑(校园跑的核心规则)")
print(" · 是否跑过该次要求的**必打点 / 必经点**")
print(" · 有效里程是否达到最低门槛(很多学校要求单次 ≥1~2 公里)")
print(" · App 是否要求前台运行 + 屏幕常亮 + 后台定位权限")
return 0
if __name__ == "__main__":
raise SystemExit(main())