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

+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"]