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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
11 KiB
11 KiB
虚拟定位轨迹工具 — 前后端接口契约(v1,冻结)
所有实现必须严格遵守本文件。任何一方需要改动契约,必须先改本文件并通知对方。
1. 进程与端口
- 单进程:Python HTTP/WebSocket 服务监听
127.0.0.1:8787(可用--port覆盖)。 - 静态前端由服务端从
web/目录提供,入口GET /→web/index.html。 - 无外部网络依赖(除地图瓦片与可选路线规划 OSRM)。
2. REST API
所有响应 Content-Type: application/json; charset=utf-8。失败统一返回:
{"ok": false, "error": "<中文可读信息>", "code": "<machine_code>"},HTTP 状态码 400/404/409/500。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/health |
{"ok":true,"version":"1.0.0","mock":false,"device_count":1} |
| GET | /api/devices |
设备列表(见 2.1) |
| POST | /api/devices/select |
body {"udid":"..."} → {"ok":true,"device":<Device>} |
| POST | /api/devices/connect |
建立隧道/DVT 会话 → {"ok":true,"device":<Device>,"tunnel":{"state":"connected","ios_major":26}} |
| POST | /api/devices/disconnect |
释放会话 → {"ok":true} |
| GET | /api/config |
{"ok":true,"config":{...}} |
| POST | /api/config |
局部更新,body 为部分字段 → {"ok":true,"config":{...}} |
| GET | /api/settings/tiles |
瓦片源列表 + 当前选择 |
| GET | /api/tiles/{source}/{z}/{x}/{y}.png |
瓦片代理 + 本地磁盘缓存;命中/回源均返回图片字节;全部源失败 → 404 + JSON 错误 |
| POST | /api/route/build |
body {"waypoints":[[lng,lat],...],"mode":"driving"} → {"ok":true,"route":<Route>,"engine":"osrm"|"straight"} |
| POST | /api/gpx/export |
body {"route":<Route>,"name":"..."} → 返回 GPX 文本(application/gpx+xml) |
| POST | /api/gpx/import |
body 为 GPX 文本(text/plain 或 multipart)→ {"ok":true,"waypoints":[[lng,lat],...],"path":[...],"points":[...],"name":"...","count":N}。三个数组内容相同:全量坐标、不抽稀,前端用 path 保形重放 |
| POST | /api/tunnel |
body {} 建隧道(可选 {"sudo":true} 走管理员);或 {"host":"...","port":1234} 手动指定 → {"ok":true,"tunnel":{...}} |
2.1 Device 对象
{
"udid": "00008110-000A1B2C3D4E801E",
"name": "iPhone 15 Pro",
"product_type": "iPhone16,1",
"ios_version": "26.0",
"ios_major": 26,
"connection": "usb",
"state": "connected",
"tunnel": "idle",
"simulating": false,
"developer_mode": true,
"tunnel_hint": null,
"last_error": null
}
state:connected|unpaired|unavailabletunnel:idle|starting|connected|error|unsupportedtunnel_hint: iOS≥17 需要隧道而失败时的中文修复建议(例如「需要管理员权限建立隧道:sudo ./run.sh」),否则null。developer_mode: iOS≥16 未开启开发者模式时为false,前端需显著提示。
2.2 Route 对象
{
"id": "r-1712345678901",
"name": "未命名轨迹",
"mode": "driving",
"waypoints": [[116.397, 39.908], [116.401, 39.912]],
"path": [[116.397, 39.908], [116.3971, 39.9082]],
"speeds": [8.3, 8.5],
"distance": 1234.5,
"duration": 148.6,
"engine": "osrm",
"loop": false,
"speed_scale": 1.0
}
waypoints用户途经点(含起点/终点),path是实际驱动用的密集坐标序列([lng,lat],WGS84)。speeds:与path等长的瞬时速度 m/s(仅用于展示与导出);speed_scale是播放倍速。distance米;duration秒(= 按speeds积分的结果)。
2.3 配置文件 config.json(与 main.py 同目录)
{
"tile_source": "amap",
"tile_sources": {
"amap": {"name": "高德矢量", "url": "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}", "subdomains": ["1","2","3","4"], "max_zoom": 18, "attribution": "© 高德地图"},
"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": "© 高德地图"},
"tencent": {"name": "腾讯地图", "url": "https://rt{s}.map.gtimg.com/tile?z={z}&x={x}&y={y}&type=vector&styleid=3", "subdomains": ["0","1","2","3"], "max_zoom": 18, "attribution": "© 腾讯地图"},
"arcgis_sat": {"name": "全球卫星", "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", "subdomains": [], "max_zoom": 19, "attribution": "Esri"}
},
"tile_cache_dir": "~/.ios-location-tiles",
"tile_cache_max_mb": 1024,
"send_hz": 5,
"default_speed_scale": 1.0,
"mode_defaults": {"driving": 13.9, "cycling": 5.5, "walking": 1.4, "flight": 60.0},
"osrm_url": "https://router.project-osrm.org/route/v1"
}
坐标系统一为 WGS84。前端若使用高德瓦片,仅瓦片源不同(高德瓦片为 GCJ-02),显示用点必须做 WGS84→GCJ-02 转换后再落到瓦片上,反向转换用于取点。转换函数放在 web/js/coord.js,导出 wgs84ToGcj02 / gcj02ToWgs84。后端永远只接收/返回 WGS84。
3. WebSocket API
- 地址:
ws://127.0.0.1:8787/ws。UTF-8 文本帧,每条消息一个 JSON 对象。 - 客户端 → 服务端:
{"type": "<cmd>", "id": "<可选请求id>", ...} - 服务端 → 客户端:
{"type": "<event>", "id": "<回填请求id>", ...},id存在时表示对某条命令的应答。
3.1 客户端命令
| type | 字段 | 说明 |
|---|---|---|
hello |
{"client":"web"} |
连接后立即发送,服务端回 welcome |
ping |
{} |
回 pong,用于心跳(客户端每 10s 发一次) |
devices.refresh |
{} |
回 devices |
device.select |
{"udid":""} |
回 device + tunnel |
device.connect |
{} |
回 device/tunnel,可能先推多条 log |
device.disconnect |
{} |
回 device/tunnel |
location.set |
{"lng":116.397,"lat":39.908} |
单点定位,回 ack,随后推 location |
location.clear |
{} |
停止模拟、恢复真实定位,回 ack |
play.start |
{"route":<Route>,"speed_scale":1.0,"loop":false,"start_index":0} |
开始沿轨迹播放。服务端以收到的 route 为准(前端可只发 path/speeds)。回 ack;随后持续推 status/location |
play.pause |
{} |
回 ack |
play.resume |
{} |
回 ack |
play.stop |
{} |
停止播放并停止模拟(恢复真实定位),回 ack |
play.seek |
{"progress":0.42} |
0..1,回 ack 并推 location |
play.speed |
{"speed_scale":2.0} |
播放倍速 0.1..50,回 ack |
log.list |
{"limit":200} |
回 logs |
route.build |
{"waypoints":[[lng,lat],...],"mode":"driving","name":"...","use_osrm":true,"loop":false,"speed_scale":1.0} |
回 ack{route,engine};便于前端在 WS 上一次完成规划 |
tunnel.ensure |
{"sudo":false} |
建立/复用隧道 → ack{tunnel,need_tunnel}。need_tunnel:false 表示该场景不需要隧道(mock 模式,或 iOS<17 走 usbmux 经典通道),前端应立即结束等待 |
tunnel.manual |
{"host":"fd00::1","port":51234} |
使用手动指定的 RSD → ack{tunnel} |
tiles.clear |
{} |
清空瓦片磁盘缓存 → ack{removed:N} |
config.update |
{"config":{"send_hz":5,"tile_source":"amap"}} |
局部更新配置 → ack{config} |
3.2 服务端事件
| type | 字段 | 说明 |
|---|---|---|
welcome |
{"version":"1.0.0","mock":false,"config":{...}} |
连接就绪,附带当前配置与设备快照 |
pong |
{"ts":1712345678.1} |
心跳应答 |
ack |
{"id":"<回填>","cmd":"play.start"} 或 {"id":"...","ok":false,"error":"..."} |
命令应答 |
devices |
{"devices":[<Device>...],"selected":"<udid|null>"} |
设备列表变化 |
device |
{"device":<Device>|null} |
单设备状态变化 |
tunnel |
{"state":"connected","ios_major":26,"message":"隧道已建立(RSD)","rsd":"fd00::1 端口 51234"} |
隧道状态变化 |
location |
{"lng":116.397,"lat":39.908,"source":"play"|"manual","progress":0.42,"index":1234,"speed":8.31,"sent_at":1712345678.123} |
每次实际下发定位后推送 |
status |
{"playing":true,"paused":false,"progress":0.42,"index":1234,"total":3000,"elapsed":61.2,"remaining":88.0,"speed_scale":1.0,"loop":false,"distance_done":512.3,"lng":116.39,"lat":39.9,"seq":7} |
播放状态,播放中约 2Hz 推送,状态变化时立即推送。seq 为自增序号,用于判断新旧状态 |
logs |
{"lines":[{"ts":1712345678.1,"level":"info","msg":"..."}]} |
日志回填 |
log |
{"ts":...,"level":"info"|"warn"|"error"|"success","msg":"..."} |
实时日志 |
error |
{"error":"中文信息","code":"..."} |
服务端异常 |
3.3 语义约定
play.start时若尚未连接设备:立即回ack ok:false, error:"请先连接设备"(mock 模式除外)。play.stop与location.clear必须调用 DVTclear_location,让手机恢复真实定位,并额外推一条{"type":"location","source":"cleared","lng":null,"lat":null},前端应让地图上的定位点消失。- 播放循环
loop=true时到终点自动回到起点,status.loop保持true。 - 服务端播放引擎按
send_hz(默认 5Hz)下发;play.speed只影响轨迹推进速率,不影响下发频率。 - 逐次下发的日志按 5 秒聚合成一条「正在按轨迹下发定位(已下发 N 次)」,不会 5Hz 刷屏。
- 所有
lng/lat单位度,WGS84,保留 6 位小数。 hello的应答是ack(welcome的数据并进 ack,没有独立的type:"welcome"消息):{type:"ack", id, cmd:"hello", version, name, mock, config, devices, selected, tunnel, backend, pymobiledevice3}。- WebSocket 子协议:只有客户端在
Sec-WebSocket-Protocol里请求了值时才回该头(RFC 6455 要求), 否则浏览器会判定握手失败。
4. 无设备模拟模式(验收必备)
启动参数 --mock 或环境变量 IOSLOC_MOCK=1 时:
/api/devices返回一台假设备:udid="MOCK-0001",name="模拟设备 (iPhone 17 Pro)",ios_version="26.0",ios_major=26,state="connected",tunnel="connected"。- 定位下发不报错,仅记录日志并照常推送
location事件。 - 其余行为(播放引擎、进度、GPX、瓦片缓存)与真机完全一致,用于端到端验证。
5. 模块与文件所有权(写作用域)
| 文件 | 负责人 |
|---|---|
core/protocol.py |
lead |
core/ws.py |
lead |
core/server.py |
lead |
core/route_engine.py |
lead |
core/tilecache.py |
lead |
core/device.py |
lead |
core/tunnel.py |
lead |
core/location_provider.py |
device-teammate |
core/location_mock.py |
device-teammate |
core/playback.py |
lead |
core/config_store.py |
lead |
main.py |
lead |
web/index.html, web/style.css |
web-teammate |
web/js/*.js |
web-teammate |
tools/*.py |
device-teammate |
scripts/*, README.md |
lead |
任何人不得直接改别人拥有的文件;需要改动就发消息商量。