feat: 把 A 端的 Session/KCP/视频/控制 都收口到一个本地 daemon 进程里,Django 和输入发送端都改成通过本机 UDS HTTP 去访问它,同时补齐了观测、性能和可用性上的几个关键问题。
This commit is contained in:
@@ -17,6 +17,7 @@ INSTALLED_APPS = [
|
|||||||
'rest_framework',
|
'rest_framework',
|
||||||
'channels',
|
'channels',
|
||||||
'monitoring',
|
'monitoring',
|
||||||
|
'control',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ 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')),
|
||||||
]
|
]
|
||||||
|
|||||||
1
backend/control/__init__.py
Normal file
1
backend/control/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "control.apps.ControlConfig"
|
||||||
6
backend/control/apps.py
Normal file
6
backend/control/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ControlConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "control"
|
||||||
51
backend/control/services.py
Normal file
51
backend/control/services.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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()
|
||||||
9
backend/control/urls.py
Normal file
9
backend/control/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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"),
|
||||||
|
]
|
||||||
59
backend/control/views.py
Normal file
59
backend/control/views.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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)
|
||||||
@@ -2,9 +2,7 @@ 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
|
||||||
@@ -13,320 +11,104 @@ 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
|
||||||
OMNISOCKET_CONFIG_PATH = PROJECT_ROOT / "config" / "omnisocket_demo.yaml"
|
DAEMON_FRAME_URI = "omni-daemon://latest-frame"
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
class OmniSocketVideoReceiver:
|
def _load_daemon_client_api():
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._started = False
|
|
||||||
self._session = None
|
|
||||||
self._session_cls = None
|
|
||||||
self._binary_msg_type = None
|
|
||||||
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:
|
try:
|
||||||
self._import_backend()
|
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
|
||||||
except Exception as error: # pragma: no cover - 可选的运行时依赖
|
|
||||||
self._last_error = f"omnisocket import failed: {error}"
|
|
||||||
|
|
||||||
def _import_backend(self) -> None:
|
|
||||||
# 优先使用已经安装到当前 Python 环境里的 omnisocket。
|
|
||||||
# 如果导入失败,再尝试样例目录下的本地 python 路径。
|
|
||||||
try:
|
|
||||||
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
|
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
|
||||||
if python_dir.exists():
|
if python_dir.exists():
|
||||||
sys.path.insert(0, str(python_dir))
|
sys.path.insert(0, str(python_dir))
|
||||||
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
|
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
|
||||||
|
return OmniDaemonClient, OmniDaemonError
|
||||||
|
|
||||||
self._binary_msg_type = MSG_TYPE_BINARY
|
|
||||||
self._session_cls = Session
|
|
||||||
self._video_defaults = dict(VIDEO_DEFAULTS)
|
|
||||||
|
|
||||||
def ensure_started(self) -> None:
|
_OmniDaemonClient, OmniDaemonError = _load_daemon_client_api()
|
||||||
# 当第一次请求帧或状态时,再懒启动后台接收线程。
|
_daemon_client = _OmniDaemonClient()
|
||||||
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 _load_config(self) -> dict[str, Any]:
|
def get_daemon_client():
|
||||||
# 这里保持和 SampleCData/omnisocket_video_receiver.py 一样的配置结构:
|
return _daemon_client
|
||||||
# transport + video_receiver。
|
|
||||||
# 即使配置文件不存在,也允许回退到默认值继续运行。
|
|
||||||
config: dict[str, Any] = {}
|
|
||||||
if OMNISOCKET_CONFIG_PATH.exists():
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
import yaml # type: ignore
|
|
||||||
|
|
||||||
with OMNISOCKET_CONFIG_PATH.open("r", encoding="utf-8") as file:
|
|
||||||
config = yaml.safe_load(file) or {}
|
|
||||||
except ImportError:
|
|
||||||
# 当前配置文件结构非常简单,缺少 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)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def _default_receiver(error_message: str) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"transport": transport_cfg,
|
"backend_ready": False,
|
||||||
"video_receiver": video_cfg,
|
"mode": "daemon",
|
||||||
}
|
"connected": False,
|
||||||
|
"has_recent_frame": False,
|
||||||
def _load_simple_yaml_config(self, path: Path) -> dict[str, Any]:
|
"frames_received": 0,
|
||||||
parsed: dict[str, Any] = {}
|
"latest_sequence": None,
|
||||||
current_section: str | None = None
|
"last_error": error_message,
|
||||||
|
"config_path": "",
|
||||||
with path.open("r", encoding="utf-8") as file:
|
"server_addr": "",
|
||||||
for raw_line in file:
|
"relay_via": "",
|
||||||
line = raw_line.split("#", 1)[0].rstrip()
|
"peer_id": "",
|
||||||
if not line.strip():
|
"buffer_bytes": 0,
|
||||||
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]:
|
||||||
receiver_status = self._receiver.get_status()
|
try:
|
||||||
receiver_frame = self._receiver.get_latest_frame()
|
state = get_daemon_client().get_state()
|
||||||
|
except OmniDaemonError as error:
|
||||||
# 如果已经收到了真实视频帧,就把当前状态标记为实时模式,给前端显示。
|
|
||||||
if receiver_frame is not None:
|
|
||||||
return {
|
|
||||||
"available": True,
|
|
||||||
"source_mode": "omnisocket-jpeg-live",
|
|
||||||
"frame_count": receiver_status["frames_received"],
|
|
||||||
"fps": 30,
|
|
||||||
"frame_dir": str(JPEG_FRAME_DIR),
|
|
||||||
"source_detail": f"peer stream active, frames={receiver_status['frames_received']}",
|
|
||||||
"receiver": receiver_status,
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_detail = receiver_status["last_error"] or (
|
|
||||||
"未实时获取真实值,请检查 OmniSocket 服务、视频发送端和接收配置。"
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"available": False,
|
"available": False,
|
||||||
"source_mode": "omnisocket-waiting",
|
"source_mode": "daemon-unavailable",
|
||||||
"frame_count": receiver_status["frames_received"],
|
"frame_count": 0,
|
||||||
"fps": 30,
|
"fps": 5,
|
||||||
"frame_dir": str(JPEG_FRAME_DIR),
|
"frame_dir": DAEMON_FRAME_URI,
|
||||||
"source_detail": wait_detail,
|
"source_detail": str(error),
|
||||||
"receiver": receiver_status,
|
"receiver": _default_receiver(str(error)),
|
||||||
|
}
|
||||||
|
|
||||||
|
video = dict(state.get("video") or {})
|
||||||
|
receiver = dict(video.get("receiver") or {})
|
||||||
|
profile = dict((state.get("policy") or {}).get("recommended_video_profile") or {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": bool(video.get("available", False)),
|
||||||
|
"source_mode": str(video.get("source_mode") or "omnisocket-waiting"),
|
||||||
|
"frame_count": int(
|
||||||
|
video.get("frame_count", receiver.get("frames_received", 0)) or 0
|
||||||
|
),
|
||||||
|
"fps": int(video.get("fps", profile.get("fps", 5)) or 5),
|
||||||
|
"frame_dir": str(video.get("frame_dir") or DAEMON_FRAME_URI),
|
||||||
|
"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:
|
||||||
# 优先返回从 Python/C 视频接收器拿到的最新真实 JPEG 帧。
|
try:
|
||||||
receiver_frame = self._receiver.get_latest_frame()
|
return get_daemon_client().get_video_frame()
|
||||||
if receiver_frame is not None:
|
except OmniDaemonError as error:
|
||||||
return receiver_frame
|
raise RuntimeError(str(error)) from error
|
||||||
|
|
||||||
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))
|
||||||
@@ -344,24 +126,17 @@ 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
|
||||||
@@ -373,7 +148,6 @@ 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
|
||||||
@@ -395,26 +169,41 @@ class GpsDataService:
|
|||||||
|
|
||||||
class NetworkTelemetryService:
|
class NetworkTelemetryService:
|
||||||
def get_latest(self) -> dict[str, Any]:
|
def get_latest(self) -> dict[str, Any]:
|
||||||
tick = time.time()
|
try:
|
||||||
|
state = get_daemon_client().get_state()
|
||||||
latency_ms = 28 + math.sin(tick / 4.0) * 6
|
except OmniDaemonError as error:
|
||||||
jitter_ms = 3 + math.cos(tick / 5.0) * 1.4
|
|
||||||
tx_kbps = 780 + math.sin(tick / 6.0) * 160
|
|
||||||
rx_kbps = 720 + math.cos(tick / 7.0) * 140
|
|
||||||
packet_loss_pct = max(0.0, 0.35 + math.sin(tick / 9.0) * 0.25)
|
|
||||||
signal_dbm = -53 - abs(math.sin(tick / 8.0) * 7)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"peer_status": "online",
|
"peer_status": "offline",
|
||||||
"latency_ms": round(latency_ms, 1),
|
"latency_ms": 0.0,
|
||||||
"jitter_ms": round(jitter_ms, 1),
|
"jitter_ms": 0.0,
|
||||||
"packet_loss_pct": round(packet_loss_pct, 2),
|
"retrans_pct": 0.0,
|
||||||
"tx_kbps": int(tx_kbps),
|
"packet_loss_pct": 0.0,
|
||||||
"rx_kbps": int(rx_kbps),
|
"tx_kbps": 0,
|
||||||
"signal_dbm": round(signal_dbm, 1),
|
"rx_kbps": 0,
|
||||||
"transport": "OmniSocket / simulated",
|
"signal_dbm": None,
|
||||||
"source_mode": "simulated",
|
"transport": "OmniSocket / daemon",
|
||||||
|
"source_mode": "daemon-unavailable",
|
||||||
"updated_at": utc_iso_now(),
|
"updated_at": utc_iso_now(),
|
||||||
|
"error": str(error),
|
||||||
|
}
|
||||||
|
|
||||||
|
network = dict(state.get("network") or {})
|
||||||
|
return {
|
||||||
|
"peer_status": str(network.get("peer_status") or "offline"),
|
||||||
|
"latency_ms": float(network.get("latency_ms", 0.0) or 0.0),
|
||||||
|
"jitter_ms": float(network.get("jitter_ms", 0.0) or 0.0),
|
||||||
|
"retrans_pct": float(
|
||||||
|
network.get("retrans_pct", network.get("packet_loss_pct", 0.0)) or 0.0
|
||||||
|
),
|
||||||
|
"packet_loss_pct": float(
|
||||||
|
network.get("packet_loss_pct", network.get("retrans_pct", 0.0)) or 0.0
|
||||||
|
),
|
||||||
|
"tx_kbps": int(network.get("tx_kbps", 0) or 0),
|
||||||
|
"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()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
transport:
|
transport:
|
||||||
server_addr: "127.0.0.1:10909"
|
server_addr: ""
|
||||||
relay_via: ""
|
relay_via: 106.55.173.235:10909
|
||||||
bind_ip: ""
|
bind_ip: ""
|
||||||
bind_device: ""
|
bind_device: ""
|
||||||
|
|
||||||
|
|||||||
@@ -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>丢包率</span>
|
<span>Retrans</span>
|
||||||
<strong>{{ network?.packet_loss_pct ?? '--' }} %</strong>
|
<strong>{{ network?.retrans_pct ?? network?.packet_loss_pct ?? '--' }} %</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>信号强度</span>
|
<span>信号强度</span>
|
||||||
@@ -148,4 +148,3 @@ h2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? 2000)
|
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 500)
|
||||||
|
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ export interface NetworkTelemetry {
|
|||||||
peer_status: string
|
peer_status: string
|
||||||
latency_ms: number
|
latency_ms: number
|
||||||
jitter_ms: number
|
jitter_ms: number
|
||||||
packet_loss_pct: number
|
retrans_pct: number
|
||||||
|
packet_loss_pct?: number
|
||||||
tx_kbps: number
|
tx_kbps: number
|
||||||
rx_kbps: number
|
rx_kbps: number
|
||||||
signal_dbm: number
|
signal_dbm: number | null
|
||||||
transport: string
|
transport: string
|
||||||
source_mode: string
|
source_mode: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
Reference in New Issue
Block a user