Revert "feat: 把 A 端的 Session/KCP/视频/控制 都收口到一个本地 daemon 进程里,Django 和输入发送端都改成通过本机 UDS HTTP 去访问它,同时补齐了观测、性能和可用性上的几个关键问题。"

This reverts commit aca23e91d7.
This commit is contained in:
2026-04-01 22:15:47 +08:00
parent aca23e91d7
commit 1e828cc036
12 changed files with 325 additions and 242 deletions

View File

@@ -17,7 +17,6 @@ INSTALLED_APPS = [
'rest_framework', 'rest_framework',
'channels', 'channels',
'monitoring', 'monitoring',
'control',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -4,5 +4,4 @@ from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/', include('monitoring.urls')), path('api/', include('monitoring.urls')),
path('api/control/', include('control.urls')),
] ]

View File

@@ -1 +0,0 @@
default_app_config = "control.apps.ControlConfig"

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class ControlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "control"

View File

@@ -1,51 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
WORKSPACE_ROOT = PROJECT_ROOT.parent
def _load_client_api():
try:
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
except ImportError:
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
if python_dir.exists():
sys.path.insert(0, str(python_dir))
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
return OmniDaemonClient, OmniDaemonError
_OmniDaemonClient, OmniDaemonError = _load_client_api()
_daemon_client = _OmniDaemonClient()
def get_daemon_client():
return _daemon_client
class ControlProxyService:
def get_status(self) -> dict[str, Any]:
return get_daemon_client().get_control_status()
def send_event(
self,
*,
event_code: str,
drive_value: float = 1.0,
source: str = "django-api",
client_time_ms: int | None = None,
) -> dict[str, Any]:
return get_daemon_client().send_control_event(
source=source,
event_code=event_code,
drive_value=drive_value,
client_time_ms=client_time_ms,
)
control_service = ControlProxyService()

View File

@@ -1,9 +0,0 @@
from django.urls import path
from . import views
urlpatterns = [
path("event/", views.control_event, name="control-event"),
path("status/", views.control_status, name="control-status"),
]

View File

@@ -1,59 +0,0 @@
from __future__ import annotations
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .services import OmniDaemonError, control_service
@api_view(["GET"])
def control_status(request):
try:
return Response(control_service.get_status())
except OmniDaemonError as error:
return Response(
{
"connected": False,
"queue_depth": 0,
"last_seq_id": None,
"last_error": str(error),
"peer_id": "",
"target_peer": "",
},
status=503,
)
@api_view(["POST"])
def control_event(request):
event_code = str(request.data.get("event_code", "")).strip()
if not event_code:
return Response({"error": "event_code is required"}, status=400)
try:
drive_value = float(request.data.get("drive_value", 1.0))
except (TypeError, ValueError):
return Response({"error": "drive_value must be numeric"}, status=400)
raw_client_time_ms = request.data.get("client_time_ms")
if raw_client_time_ms in (None, ""):
client_time_ms = None
else:
try:
client_time_ms = int(raw_client_time_ms)
except (TypeError, ValueError):
return Response({"error": "client_time_ms must be an integer"}, status=400)
source = str(request.data.get("source", "django-api")).strip() or "django-api"
try:
payload = control_service.send_event(
event_code=event_code,
drive_value=drive_value,
source=source,
client_time_ms=client_time_ms,
)
except OmniDaemonError as error:
return Response({"error": str(error)}, status=503)
return Response(payload, status=200 if payload.get("accepted") else 503)

View File

@@ -2,7 +2,9 @@ from __future__ import annotations
import json import json
import math import math
import os
import sys import sys
import threading
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -11,104 +13,320 @@ from typing import Any, Iterator
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
WORKSPACE_ROOT = PROJECT_ROOT.parent WORKSPACE_ROOT = PROJECT_ROOT.parent
JPEG_FRAME_DIR = WORKSPACE_ROOT / "RobotDataShow" / "jpeg-frames"
# GPS 数据 JSON 文件路径
GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json" GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
GEOSTREAM_STALE_SECONDS = 15 GEOSTREAM_STALE_SECONDS = 15
DAEMON_FRAME_URI = "omni-daemon://latest-frame" OMNISOCKET_CONFIG_PATH = PROJECT_ROOT / "config" / "omnisocket_demo.yaml"
VIDEO_SOURCE_MODE = os.getenv("VIDEO_SOURCE_MODE", "auto").strip().lower()
OMNISOCKET_FRAME_FRESH_SECONDS = 2.0
def utc_iso_now() -> str: def utc_iso_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
def _load_daemon_client_api(): class OmniSocketVideoReceiver:
try: def __init__(self) -> None:
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError self._lock = threading.Lock()
except ImportError: self._thread: threading.Thread | None = None
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python" self._started = False
if python_dir.exists(): self._session = None
sys.path.insert(0, str(python_dir)) self._session_cls = None
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError self._binary_msg_type = None
return OmniDaemonClient, OmniDaemonError self._video_defaults: dict[str, Any] = {}
self._latest_frame: bytes | None = None
self._latest_received_at = 0.0
self._latest_sequence: int | None = None
self._frames_received = 0
self._last_error = ""
self._load_backend()
def _load_backend(self) -> None:
# 服务启动时先尝试导入一次 Python/C 扩展。
try:
self._import_backend()
except Exception as error: # pragma: no cover - 可选的运行时依赖
self._last_error = f"omnisocket import failed: {error}"
_OmniDaemonClient, OmniDaemonError = _load_daemon_client_api() def _import_backend(self) -> None:
_daemon_client = _OmniDaemonClient() # 优先使用已经安装到当前 Python 环境里的 omnisocket。
# 如果导入失败,再尝试样例目录下的本地 python 路径。
try:
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
except ImportError:
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
if python_dir.exists():
sys.path.insert(0, str(python_dir))
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
self._binary_msg_type = MSG_TYPE_BINARY
self._session_cls = Session
self._video_defaults = dict(VIDEO_DEFAULTS)
def get_daemon_client(): def ensure_started(self) -> None:
return _daemon_client # 当第一次请求帧或状态时,再懒启动后台接收线程。
if self._session_cls is None or self._binary_msg_type is None:
return
with self._lock:
if self._started:
return
self._started = True
self._thread = threading.Thread(
target=self._run,
name="omnisocket-video-receiver",
daemon=True,
)
self._thread.start()
def _default_receiver(error_message: str) -> dict[str, Any]: def _load_config(self) -> dict[str, Any]:
return { # 这里保持和 SampleCData/omnisocket_video_receiver.py 一样的配置结构:
"backend_ready": False, # transport + video_receiver。
"mode": "daemon", # 即使配置文件不存在,也允许回退到默认值继续运行。
"connected": False, config: dict[str, Any] = {}
"has_recent_frame": False, if OMNISOCKET_CONFIG_PATH.exists():
"frames_received": 0, try:
"latest_sequence": None, try:
"last_error": error_message, import yaml # type: ignore
"config_path": "",
"server_addr": "", with OMNISOCKET_CONFIG_PATH.open("r", encoding="utf-8") as file:
"relay_via": "", config = yaml.safe_load(file) or {}
"peer_id": "", except ImportError:
"buffer_bytes": 0, # 当前配置文件结构非常简单,缺少 PyYAML 时用简化解析器兜底。
} config = self._load_simple_yaml_config(OMNISOCKET_CONFIG_PATH)
except Exception as error: # pragma: no cover - 可选依赖
self._last_error = f"config load failed: {error}"
transport_cfg = dict(config.get("transport", {}))
video_cfg = dict(config.get("video_receiver", {}))
transport_cfg["server_addr"] = os.getenv(
"OMNISOCKET_SERVER_ADDR",
str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
)
transport_cfg["relay_via"] = os.getenv(
"OMNISOCKET_RELAY_VIA",
str(transport_cfg.get("relay_via", "")),
)
transport_cfg["bind_ip"] = os.getenv(
"OMNISOCKET_BIND_IP",
str(transport_cfg.get("bind_ip", "")),
)
transport_cfg["bind_device"] = os.getenv(
"OMNISOCKET_BIND_DEVICE",
str(transport_cfg.get("bind_device", "")),
)
video_cfg["peer_id"] = os.getenv(
"OMNISOCKET_VIDEO_PEER_ID",
str(video_cfg.get("peer_id", "peer-a-video")),
)
video_cfg["buffer_bytes"] = int(
os.getenv(
"OMNISOCKET_BUFFER_BYTES",
str(video_cfg.get("buffer_bytes", 1024 * 1024)),
)
)
return {
"transport": transport_cfg,
"video_receiver": video_cfg,
}
def _load_simple_yaml_config(self, path: Path) -> dict[str, Any]:
parsed: dict[str, Any] = {}
current_section: str | None = None
with path.open("r", encoding="utf-8") as file:
for raw_line in file:
line = raw_line.split("#", 1)[0].rstrip()
if not line.strip():
continue
if not line.startswith(" "):
if not line.endswith(":"):
raise ValueError(f"invalid top-level yaml line: {raw_line.strip()}")
current_section = line[:-1].strip()
parsed[current_section] = {}
continue
if current_section is None:
raise ValueError(f"yaml key outside section: {raw_line.strip()}")
stripped = line.strip()
if ":" not in stripped:
raise ValueError(f"invalid yaml key line: {raw_line.strip()}")
key, value = stripped.split(":", 1)
parsed[current_section][key.strip()] = self._parse_simple_yaml_scalar(value.strip())
return parsed
def _parse_simple_yaml_scalar(self, value: str) -> Any:
if value in {'""', "''"}:
return ""
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
if value.lower() == "true":
return True
if value.lower() == "false":
return False
if value and value.lstrip("-").isdigit():
return int(value)
return value
def _connect_session(self):
# 这里和样例接收器一致:创建 Session(),然后使用 transport/video 配置建立连接。
assert self._session_cls is not None
config = self._load_config()
transport_cfg = config.get("transport", {})
video_cfg = config.get("video_receiver", {})
session = self._session_cls()
session.connect(
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
peer_id=str(video_cfg.get("peer_id", "peer-a-video")),
relay_via=str(transport_cfg.get("relay_via", "")),
bind_ip=str(transport_cfg.get("bind_ip", "")),
bind_device=str(transport_cfg.get("bind_device", "")),
**self._video_defaults,
)
return session, int(video_cfg.get("buffer_bytes", 1024 * 1024))
def _extract_jpeg_frame(self, frame: bytes) -> bytes | None:
# 同时兼容两种帧格式:
# 1. 纯 JPEG 二进制
# 2. 前 8 字节是序号,后面才是真正的 JPEG 数据
if frame.startswith(b"\xff\xd8"):
return frame
if len(frame) > 8 and frame[8:10] == b"\xff\xd8":
return frame[8:]
return None
def _extract_sequence(self, frame: bytes) -> int | None:
if len(frame) >= 8 and not frame.startswith(b"\xff\xd8"):
return int.from_bytes(frame[:8], "big")
return None
def _run(self) -> None:
# 后台持续接收循环:
# connect -> recv_into(buffer) -> 按 body_len 截出有效内容 -> 把最新 JPEG 帧缓存在内存里
while True:
try:
session, buffer_bytes = self._connect_session()
self._session = session
self._last_error = ""
buffer = bytearray(buffer_bytes)
while True:
# 从 OmniSocket / C 侧收一帧原始二进制数据到缓冲区
meta = session.recv_into(buffer, timeout_ms=1000)
if meta is None:
continue
if meta.get("msg_type") != self._binary_msg_type:
continue
# 真正收到的有效部分切出来,形成当前这一帧
frame = bytes(buffer[: meta["body_len"]])
jpeg_frame = self._extract_jpeg_frame(frame)
if jpeg_frame is None:
self._last_error = "received non-JPEG binary frame"
continue
with self._lock:
# 缓存:这里只保留最新的一张 JPEG 帧,供 Web 接口直接返回给前端。
self._latest_frame = jpeg_frame
self._latest_received_at = time.time()
self._latest_sequence = self._extract_sequence(frame)
self._frames_received += 1
except Exception as error: # pragma: no cover - 运行时集成路径
self._last_error = str(error)
time.sleep(2)
finally:
if self._session is not None:
try:
self._session.close()
except Exception:
pass
self._session = None
def get_latest_frame(self) -> bytes | None:
# 把内存里最新的一张真实 JPEG 帧暴露给 Django 视图层。
# 如果这张帧已经过旧,就返回 None让上层回退到本地模拟帧。
self.ensure_started()
with self._lock:
if self._latest_frame is None:
return None
if time.time() - self._latest_received_at > OMNISOCKET_FRAME_FRESH_SECONDS:
return None
return self._latest_frame
def get_status(self) -> dict[str, Any]:
self.ensure_started()
config = self._load_config()
transport_cfg = config.get("transport", {})
video_cfg = config.get("video_receiver", {})
with self._lock:
has_recent_frame = self._latest_frame is not None and (
time.time() - self._latest_received_at <= OMNISOCKET_FRAME_FRESH_SECONDS
)
return {
"backend_ready": self._session_cls is not None,
"mode": VIDEO_SOURCE_MODE,
"connected": self._session is not None,
"has_recent_frame": has_recent_frame,
"frames_received": self._frames_received,
"latest_sequence": self._latest_sequence,
"last_error": self._last_error,
"config_path": str(OMNISOCKET_CONFIG_PATH),
"server_addr": str(transport_cfg.get("server_addr", "")),
"relay_via": str(transport_cfg.get("relay_via", "")),
"peer_id": str(video_cfg.get("peer_id", "")),
"buffer_bytes": int(video_cfg.get("buffer_bytes", 0)),
}
class VideoFrameService: class VideoFrameService:
def __init__(self) -> None:
self._receiver = OmniSocketVideoReceiver()
def get_status(self) -> dict[str, Any]: def get_status(self) -> dict[str, Any]:
try: receiver_status = self._receiver.get_status()
state = get_daemon_client().get_state() receiver_frame = self._receiver.get_latest_frame()
except OmniDaemonError as error:
# 如果已经收到了真实视频帧,就把当前状态标记为实时模式,给前端显示。
if receiver_frame is not None:
return { return {
"available": False, "available": True,
"source_mode": "daemon-unavailable", "source_mode": "omnisocket-jpeg-live",
"frame_count": 0, "frame_count": receiver_status["frames_received"],
"fps": 5, "fps": 30,
"frame_dir": DAEMON_FRAME_URI, "frame_dir": str(JPEG_FRAME_DIR),
"source_detail": str(error), "source_detail": f"peer stream active, frames={receiver_status['frames_received']}",
"receiver": _default_receiver(str(error)), "receiver": receiver_status,
} }
video = dict(state.get("video") or {}) wait_detail = receiver_status["last_error"] or (
receiver = dict(video.get("receiver") or {}) "未实时获取真实值,请检查 OmniSocket 服务、视频发送端和接收配置。"
profile = dict((state.get("policy") or {}).get("recommended_video_profile") or {}) )
return { return {
"available": bool(video.get("available", False)), "available": False,
"source_mode": str(video.get("source_mode") or "omnisocket-waiting"), "source_mode": "omnisocket-waiting",
"frame_count": int( "frame_count": receiver_status["frames_received"],
video.get("frame_count", receiver.get("frames_received", 0)) or 0 "fps": 30,
), "frame_dir": str(JPEG_FRAME_DIR),
"fps": int(video.get("fps", profile.get("fps", 5)) or 5), "source_detail": wait_detail,
"frame_dir": str(video.get("frame_dir") or DAEMON_FRAME_URI), "receiver": receiver_status,
"source_detail": str(
video.get("source_detail")
or receiver.get("last_error")
or "waiting for latest JPEG frame from daemon"
),
"receiver": {
"backend_ready": bool(receiver.get("backend_ready", True)),
"mode": str(receiver.get("mode") or "daemon"),
"connected": bool(receiver.get("connected", False)),
"has_recent_frame": bool(receiver.get("has_recent_frame", False)),
"frames_received": int(receiver.get("frames_received", 0) or 0),
"latest_sequence": receiver.get("latest_sequence"),
"last_error": str(receiver.get("last_error") or ""),
"config_path": str(receiver.get("config_path") or ""),
"server_addr": str(receiver.get("server_addr") or ""),
"relay_via": str(receiver.get("relay_via") or ""),
"peer_id": str(receiver.get("peer_id") or ""),
"buffer_bytes": int(receiver.get("buffer_bytes", 0) or 0),
},
} }
def get_next_frame(self) -> bytes: def get_next_frame(self) -> bytes:
try: # 优先返回从 Python/C 视频接收器拿到的最新真实 JPEG 帧。
return get_daemon_client().get_video_frame() receiver_frame = self._receiver.get_latest_frame()
except OmniDaemonError as error: if receiver_frame is not None:
raise RuntimeError(str(error)) from error return receiver_frame
raise RuntimeError("未实时获取真实值,当前没有可用的真实 JPEG 帧。")
def iter_mjpeg(self, fps: float = 6.0) -> Iterator[bytes]: def iter_mjpeg(self, fps: float = 6.0) -> Iterator[bytes]:
frame_interval = 1.0 / max(1.0, min(fps, 30.0)) frame_interval = 1.0 / max(1.0, min(fps, 30.0))
@@ -126,17 +344,24 @@ class VideoFrameService:
class GpsDataService: class GpsDataService:
def get_latest(self) -> dict[str, Any]: def get_latest(self) -> dict[str, Any]:
# 优先使用由 GeoStream C 解析器生成的最新数据包。
payload = self._read_geostream_payload() payload = self._read_geostream_payload()
if payload is not None: if payload is not None:
payload["source_mode"] = "geostream-json" payload["source_mode"] = "geostream-json"
payload["updated_at"] = utc_iso_now() payload["updated_at"] = utc_iso_now()
return payload return payload
# 当没有最新的 GPS 文件可用时,退回到使用一个移动的演示点位。
return self._build_simulated_payload() return self._build_simulated_payload()
def _read_geostream_payload(self) -> dict[str, Any] | None: def _read_geostream_payload(self) -> dict[str, Any] | None:
# Django 后端目前还不直接解析串口数据流。
# 它只读取由 GeoStream/parse_gps.c 生成的 JSON 文件。
if not GEOSTREAM_JSON_PATH.exists(): if not GEOSTREAM_JSON_PATH.exists():
return None return None
# 忽略过期的文件,以便 UI 可以退回到使用模拟数据,
# 而不是把旧位置当作实时位置来显示。
age_seconds = time.time() - GEOSTREAM_JSON_PATH.stat().st_mtime age_seconds = time.time() - GEOSTREAM_JSON_PATH.stat().st_mtime
if age_seconds > GEOSTREAM_STALE_SECONDS: if age_seconds > GEOSTREAM_STALE_SECONDS:
return None return None
@@ -148,6 +373,7 @@ class GpsDataService:
return None return None
def _build_simulated_payload(self) -> dict[str, Any]: def _build_simulated_payload(self) -> dict[str, Any]:
# 为本地 UI 开发构建一个平滑的伪造轨迹。
tick = time.time() / 12.0 tick = time.time() / 12.0
latitude = 31.2304 + math.sin(tick) * 0.0014 latitude = 31.2304 + math.sin(tick) * 0.0014
longitude = 121.4737 + math.cos(tick) * 0.0018 longitude = 121.4737 + math.cos(tick) * 0.0018
@@ -169,41 +395,26 @@ class GpsDataService:
class NetworkTelemetryService: class NetworkTelemetryService:
def get_latest(self) -> dict[str, Any]: def get_latest(self) -> dict[str, Any]:
try: tick = time.time()
state = get_daemon_client().get_state()
except OmniDaemonError as error: latency_ms = 28 + math.sin(tick / 4.0) * 6
return { jitter_ms = 3 + math.cos(tick / 5.0) * 1.4
"peer_status": "offline", tx_kbps = 780 + math.sin(tick / 6.0) * 160
"latency_ms": 0.0, rx_kbps = 720 + math.cos(tick / 7.0) * 140
"jitter_ms": 0.0, packet_loss_pct = max(0.0, 0.35 + math.sin(tick / 9.0) * 0.25)
"retrans_pct": 0.0, signal_dbm = -53 - abs(math.sin(tick / 8.0) * 7)
"packet_loss_pct": 0.0,
"tx_kbps": 0,
"rx_kbps": 0,
"signal_dbm": None,
"transport": "OmniSocket / daemon",
"source_mode": "daemon-unavailable",
"updated_at": utc_iso_now(),
"error": str(error),
}
network = dict(state.get("network") or {})
return { return {
"peer_status": str(network.get("peer_status") or "offline"), "peer_status": "online",
"latency_ms": float(network.get("latency_ms", 0.0) or 0.0), "latency_ms": round(latency_ms, 1),
"jitter_ms": float(network.get("jitter_ms", 0.0) or 0.0), "jitter_ms": round(jitter_ms, 1),
"retrans_pct": float( "packet_loss_pct": round(packet_loss_pct, 2),
network.get("retrans_pct", network.get("packet_loss_pct", 0.0)) or 0.0 "tx_kbps": int(tx_kbps),
), "rx_kbps": int(rx_kbps),
"packet_loss_pct": float( "signal_dbm": round(signal_dbm, 1),
network.get("packet_loss_pct", network.get("retrans_pct", 0.0)) or 0.0 "transport": "OmniSocket / simulated",
), "source_mode": "simulated",
"tx_kbps": int(network.get("tx_kbps", 0) or 0), "updated_at": utc_iso_now(),
"rx_kbps": int(network.get("rx_kbps", 0) or 0),
"signal_dbm": network.get("signal_dbm"),
"transport": str(network.get("transport") or "OmniSocket / daemon"),
"source_mode": str(network.get("source_mode") or "daemon-live"),
"updated_at": str(network.get("updated_at") or utc_iso_now()),
} }

View File

@@ -1,6 +1,6 @@
transport: transport:
server_addr: "" server_addr: "127.0.0.1:10909"
relay_via: 106.55.173.235:10909 relay_via: ""
bind_ip: "" bind_ip: ""
bind_device: "" bind_device: ""

View File

@@ -35,8 +35,8 @@ const updatedAt = computed(() => {
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong> <strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>Retrans</span> <span>丢包率</span>
<strong>{{ network?.retrans_pct ?? network?.packet_loss_pct ?? '--' }} %</strong> <strong>{{ network?.packet_loss_pct ?? '--' }} %</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>信号强度</span> <span>信号强度</span>
@@ -148,3 +148,4 @@ h2 {
} }
} }
</style> </style>

View File

@@ -13,7 +13,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
const video = ref<VideoStatus | null>(null) const video = ref<VideoStatus | null>(null)
const loading = ref(true) const loading = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 500) const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000)
let refreshTimer: number | null = null let refreshTimer: number | null = null

View File

@@ -16,11 +16,10 @@ export interface NetworkTelemetry {
peer_status: string peer_status: string
latency_ms: number latency_ms: number
jitter_ms: number jitter_ms: number
retrans_pct: number packet_loss_pct: number
packet_loss_pct?: number
tx_kbps: number tx_kbps: number
rx_kbps: number rx_kbps: number
signal_dbm: number | null signal_dbm: number
transport: string transport: string
source_mode: string source_mode: string
updated_at: string updated_at: string