Compare commits
12 Commits
dev
...
3d5a65c6ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d5a65c6ef | |||
|
|
adb43efb12 | ||
|
|
08057baf0c | ||
|
|
18033f3b67 | ||
|
|
497a28c1b2 | ||
| ec025f1c5c | |||
| bffad61293 | |||
| 7ad26198c1 | |||
| b0dcf7b571 | |||
|
|
1a41905d4c | ||
|
|
51ea86e887 | ||
| 1e828cc036 |
@@ -9,8 +9,23 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
|
||||
import os
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.security.websocket import OriginValidator
|
||||
from django.conf import settings
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from monitoring.routing import websocket_urlpatterns
|
||||
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": OriginValidator(
|
||||
AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
|
||||
settings.CONTROL_WS_ALLOWED_ORIGINS,
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _split_csv_env(name: str) -> list[str]:
|
||||
value = os.getenv(name, "")
|
||||
return [item.strip().rstrip("/") for item in value.split(",") if item.strip()]
|
||||
|
||||
SECRET_KEY = 'django-insecure-pk4scm@ifo%mao6l=j0@-$_v+pg-43^hj4a!199^)zivz-_8xu'
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
@@ -17,7 +23,6 @@ INSTALLED_APPS = [
|
||||
'rest_framework',
|
||||
'channels',
|
||||
'monitoring',
|
||||
'control',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -88,3 +93,16 @@ STATIC_URL = 'static/'
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
CONTROL_WS_ALLOWED_ORIGINS = _split_csv_env('CONTROL_WS_ALLOWED_ORIGINS') or [
|
||||
'http://127.0.0.1',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:4173',
|
||||
'http://127.0.0.1:8001',
|
||||
'https://127.0.0.1',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:4173',
|
||||
'http://localhost',
|
||||
'http://localhost:8001',
|
||||
'https://localhost',
|
||||
]
|
||||
|
||||
@@ -4,5 +4,4 @@ from django.urls import include, path
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('monitoring.urls')),
|
||||
path('api/control/', include('control.urls')),
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = "control.apps.ControlConfig"
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ControlConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "control"
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
206
backend/monitoring/common.py
Normal file
206
backend/monitoring/common.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKSPACE_ROOT = PROJECT_ROOT.parent
|
||||
JPEG_FRAME_DIR = WORKSPACE_ROOT / "RobotDataShow" / "jpeg-frames"
|
||||
GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
|
||||
GEOSTREAM_STALE_SECONDS = 15
|
||||
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
|
||||
VIDEO_TIMESTAMP_SAMPLE_SIZE = 10
|
||||
VIDEO_TIMESTAMP_TRAILER_BYTES = 8
|
||||
VIDEO_TIMESTAMP_ENDIANNESS = "little"
|
||||
VIDEO_TIMESTAMP_UNIT = "ms"
|
||||
VIDEO_TIMESTAMP_MULTIPLIER_NS = 1_000_000
|
||||
VIDEO_TIMESTAMP_MAX_SKEW_NS = 7 * 24 * 60 * 60 * 1_000_000_000
|
||||
|
||||
CONTROL_PACKET = struct.Struct("<6f")
|
||||
CONTROL_PACKET_SIZE = CONTROL_PACKET.size
|
||||
CONTROL_SOURCE_NATIVE_UDP = "native_udp"
|
||||
CONTROL_SOURCE_WEB = "web"
|
||||
CONTROL_SOURCE_PRIORITY = (CONTROL_SOURCE_NATIVE_UDP, CONTROL_SOURCE_WEB)
|
||||
ZERO_CONTROL_PAYLOAD = CONTROL_PACKET.pack(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def utc_iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_simple_yaml_scalar(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 load_simple_yaml_config(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()] = parse_simple_yaml_scalar(value.strip())
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def load_omnisocket_config() -> dict[str, Any]:
|
||||
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:
|
||||
config = load_simple_yaml_config(OMNISOCKET_CONFIG_PATH)
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
transport_cfg = dict(config.get("transport", {}))
|
||||
video_receiver_cfg = dict(config.get("video_receiver", {}))
|
||||
control_sender_cfg = dict(config.get("control_sender", {}))
|
||||
control_ingress_cfg = dict(config.get("control_ingress", {}))
|
||||
video_sender_cfg = dict(config.get("video_sender", {}))
|
||||
telemetry_receiver_cfg = dict(config.get("telemetry_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_receiver_cfg["peer_id"] = os.getenv(
|
||||
"OMNISOCKET_VIDEO_PEER_ID",
|
||||
str(video_receiver_cfg.get("peer_id", "peer-a-video")),
|
||||
)
|
||||
video_receiver_cfg["buffer_bytes"] = int(
|
||||
os.getenv(
|
||||
"OMNISOCKET_BUFFER_BYTES",
|
||||
str(video_receiver_cfg.get("buffer_bytes", 1024 * 1024)),
|
||||
)
|
||||
)
|
||||
|
||||
control_sender_cfg["peer_id"] = os.getenv(
|
||||
"OMNISOCKET_CONTROL_PEER_ID",
|
||||
str(control_sender_cfg.get("peer_id", "peer-a-ctrl")),
|
||||
)
|
||||
control_sender_cfg["target_peer"] = os.getenv(
|
||||
"OMNISOCKET_CONTROL_TARGET_PEER",
|
||||
str(control_sender_cfg.get("target_peer", "peer-b-ctrl")),
|
||||
)
|
||||
|
||||
video_sender_cfg["peer_id"] = os.getenv(
|
||||
"OMNISOCKET_VIDEO_SENDER_PEER_ID",
|
||||
str(video_sender_cfg.get("peer_id", "peer-b-video")),
|
||||
)
|
||||
video_sender_cfg["target_peer"] = os.getenv(
|
||||
"OMNISOCKET_VIDEO_TARGET_PEER_ID",
|
||||
str(video_sender_cfg.get("target_peer", "peer-a-video")),
|
||||
)
|
||||
|
||||
control_ingress_cfg["native_udp_bind"] = os.getenv(
|
||||
"OMNISOCKET_CONTROL_NATIVE_UDP_BIND",
|
||||
str(control_ingress_cfg.get("native_udp_bind", "127.0.0.1:10921")),
|
||||
)
|
||||
control_ingress_cfg["source_lease_ms"] = int(
|
||||
os.getenv(
|
||||
"OMNISOCKET_CONTROL_SOURCE_LEASE_MS",
|
||||
str(control_ingress_cfg.get("source_lease_ms", 300)),
|
||||
)
|
||||
)
|
||||
control_ingress_cfg["send_rate_hz"] = float(
|
||||
os.getenv(
|
||||
"OMNISOCKET_CONTROL_SEND_RATE_HZ",
|
||||
str(control_ingress_cfg.get("send_rate_hz", 20.0)),
|
||||
)
|
||||
)
|
||||
control_ingress_cfg["zero_burst_packets"] = int(
|
||||
os.getenv(
|
||||
"OMNISOCKET_CONTROL_ZERO_BURST_PACKETS",
|
||||
str(control_ingress_cfg.get("zero_burst_packets", 3)),
|
||||
)
|
||||
)
|
||||
|
||||
telemetry_receiver_cfg["peer_id"] = os.getenv(
|
||||
"OMNISOCKET_TELEMETRY_PEER_ID",
|
||||
str(telemetry_receiver_cfg.get("peer_id", "peer-a-telemetry")),
|
||||
)
|
||||
telemetry_receiver_cfg["interval_ms"] = int(
|
||||
os.getenv(
|
||||
"OMNISOCKET_TELEMETRY_INTERVAL_MS",
|
||||
str(telemetry_receiver_cfg.get("interval_ms", 500)),
|
||||
)
|
||||
)
|
||||
telemetry_receiver_cfg["stale_after_ms"] = int(
|
||||
os.getenv(
|
||||
"OMNISOCKET_TELEMETRY_STALE_AFTER_MS",
|
||||
str(telemetry_receiver_cfg.get("stale_after_ms", telemetry_receiver_cfg["interval_ms"] * 3)),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"transport": transport_cfg,
|
||||
"video_receiver": video_receiver_cfg,
|
||||
"control_sender": control_sender_cfg,
|
||||
"control_ingress": control_ingress_cfg,
|
||||
"video_sender": video_sender_cfg,
|
||||
"telemetry_receiver": telemetry_receiver_cfg,
|
||||
}
|
||||
|
||||
|
||||
def parse_host_port(bind_addr: str) -> tuple[str, int]:
|
||||
host, port_text = bind_addr.rsplit(":", 1)
|
||||
host = host.strip() or "127.0.0.1"
|
||||
port = int(port_text)
|
||||
if port <= 0 or port > 65535:
|
||||
raise ValueError(f"invalid port in bind address: {bind_addr}")
|
||||
return host, port
|
||||
|
||||
42
backend/monitoring/consumers.py
Normal file
42
backend/monitoring/consumers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from channels.generic.websocket import WebsocketConsumer
|
||||
|
||||
from .common import CONTROL_PACKET_SIZE, CONTROL_SOURCE_WEB
|
||||
from .services import control_arbiter, native_control_ingress
|
||||
|
||||
|
||||
class ControlConsumer(WebsocketConsumer):
|
||||
def connect(self) -> None:
|
||||
control_arbiter.ensure_started()
|
||||
native_control_ingress.ensure_started()
|
||||
self.accept()
|
||||
self.send(
|
||||
text_data=json.dumps(
|
||||
{
|
||||
"type": "ready",
|
||||
"packet_bytes": CONTROL_PACKET_SIZE,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def receive(self, text_data: str | None = None, bytes_data: bytes | None = None) -> None:
|
||||
if bytes_data is None:
|
||||
self.send(text_data=json.dumps({"type": "error", "detail": "binary control payload required"}))
|
||||
return
|
||||
|
||||
if len(bytes_data) != CONTROL_PACKET_SIZE:
|
||||
self.send(
|
||||
text_data=json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"detail": f"expected {CONTROL_PACKET_SIZE} bytes, got {len(bytes_data)}",
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
control_arbiter.ingest_command(CONTROL_SOURCE_WEB, bytes_data)
|
||||
|
||||
459
backend/monitoring/control.py
Normal file
459
backend/monitoring/control.py
Normal file
@@ -0,0 +1,459 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .common import (
|
||||
CONTROL_PACKET_SIZE,
|
||||
CONTROL_SOURCE_NATIVE_UDP,
|
||||
CONTROL_SOURCE_PRIORITY,
|
||||
ZERO_CONTROL_PAYLOAD,
|
||||
WORKSPACE_ROOT,
|
||||
load_omnisocket_config,
|
||||
parse_host_port,
|
||||
)
|
||||
from .video import safe_kcp_stats
|
||||
|
||||
|
||||
class OmniSocketControlSender:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._session = None
|
||||
self._session_cls = None
|
||||
self._msg_type_error = None
|
||||
self._control_defaults: dict[str, Any] = {}
|
||||
self._started = False
|
||||
self._drain_thread: threading.Thread | None = None
|
||||
self._closing = threading.Event()
|
||||
self._target_peer = ""
|
||||
self._send_count = 0
|
||||
self._send_errors = 0
|
||||
self._drain_errors = 0
|
||||
self._last_error = ""
|
||||
self._reconnect_count = 0
|
||||
self._ever_connected = False
|
||||
self._registered = False
|
||||
self._load_backend()
|
||||
|
||||
def _load_backend(self) -> None:
|
||||
try:
|
||||
self._import_backend()
|
||||
except Exception as error: # pragma: no cover - optional runtime dependency
|
||||
self._last_error = f"omnisocket import failed: {error}"
|
||||
|
||||
def _import_backend(self) -> None:
|
||||
try:
|
||||
from omnisocket import CONTROL_DEFAULTS, MSG_TYPE_ERROR, Session # type: ignore
|
||||
except ImportError:
|
||||
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
|
||||
if python_dir.exists():
|
||||
sys.path.insert(0, str(python_dir))
|
||||
from omnisocket import CONTROL_DEFAULTS, MSG_TYPE_ERROR, Session # type: ignore
|
||||
|
||||
self._session_cls = Session
|
||||
self._msg_type_error = MSG_TYPE_ERROR
|
||||
self._control_defaults = dict(CONTROL_DEFAULTS)
|
||||
|
||||
def _connect_session(self):
|
||||
assert self._session_cls is not None
|
||||
|
||||
config = load_omnisocket_config()
|
||||
transport_cfg = config.get("transport", {})
|
||||
control_cfg = config.get("control_sender", {})
|
||||
|
||||
session = self._session_cls()
|
||||
session.connect(
|
||||
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
|
||||
peer_id=str(control_cfg.get("peer_id", "peer-a-ctrl")),
|
||||
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._control_defaults,
|
||||
)
|
||||
target_peer = str(control_cfg.get("target_peer", "peer-b-ctrl"))
|
||||
return session, target_peer
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
if self._session_cls is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if self._closing.is_set():
|
||||
return
|
||||
if self._started and self._session is not None:
|
||||
return
|
||||
session, target_peer = self._connect_session()
|
||||
self._session = session
|
||||
self._target_peer = target_peer
|
||||
self._closing.clear()
|
||||
self._started = True
|
||||
self._last_error = ""
|
||||
self._registered = bool(dict(session.stats()).get("registered", 0))
|
||||
if self._ever_connected:
|
||||
self._reconnect_count += 1
|
||||
else:
|
||||
self._ever_connected = True
|
||||
self._drain_thread = threading.Thread(
|
||||
target=self._drain_loop,
|
||||
name="omnisocket-control-drain",
|
||||
daemon=True,
|
||||
)
|
||||
self._drain_thread.start()
|
||||
|
||||
def _reset_session(self, session: Any | None) -> None:
|
||||
with self._lock:
|
||||
if session is not None and session is not self._session:
|
||||
return
|
||||
current = self._session
|
||||
self._session = None
|
||||
self._started = False
|
||||
self._registered = False
|
||||
if current is not None:
|
||||
try:
|
||||
current.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def send_payload(self, payload: bytes) -> None:
|
||||
if len(payload) != CONTROL_PACKET_SIZE:
|
||||
raise ValueError(f"expected {CONTROL_PACKET_SIZE} bytes, got {len(payload)}")
|
||||
self.ensure_started()
|
||||
with self._lock:
|
||||
session = self._session
|
||||
target_peer = self._target_peer
|
||||
|
||||
if session is None:
|
||||
raise RuntimeError("control session is not available")
|
||||
|
||||
try:
|
||||
session.send(to=target_peer, data=payload)
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
self._send_errors += 1
|
||||
self._last_error = str(error)
|
||||
self._reset_session(session)
|
||||
raise
|
||||
|
||||
with self._lock:
|
||||
self._send_count += 1
|
||||
|
||||
def send_zero_burst(self, count: int) -> None:
|
||||
for _ in range(max(0, count)):
|
||||
try:
|
||||
self.send_payload(ZERO_CONTROL_PAYLOAD)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _drain_loop(self) -> None:
|
||||
while not self._closing.is_set():
|
||||
with self._lock:
|
||||
session = self._session
|
||||
if session is None:
|
||||
return
|
||||
|
||||
try:
|
||||
result = session.recv(timeout_ms=100)
|
||||
except Exception as error:
|
||||
last_server_error = ""
|
||||
try:
|
||||
last_server_error = str(dict(session.stats()).get("last_server_error", "") or "")
|
||||
except Exception:
|
||||
last_server_error = ""
|
||||
with self._lock:
|
||||
self._drain_errors += 1
|
||||
self._registered = False
|
||||
self._last_error = last_server_error or str(error)
|
||||
if not self._closing.is_set():
|
||||
self._reset_session(session)
|
||||
return
|
||||
|
||||
if result is None:
|
||||
try:
|
||||
stats = dict(session.stats())
|
||||
except Exception:
|
||||
stats = {}
|
||||
with self._lock:
|
||||
self._registered = bool(stats.get("registered", 0))
|
||||
if stats.get("last_server_error"):
|
||||
self._last_error = str(stats.get("last_server_error"))
|
||||
continue
|
||||
|
||||
from_peer, msg_type, payload = result
|
||||
if msg_type == self._msg_type_error:
|
||||
text = payload.decode("utf-8", errors="replace")
|
||||
try:
|
||||
stats = dict(session.stats())
|
||||
except Exception:
|
||||
stats = {}
|
||||
with self._lock:
|
||||
self._last_error = f"server error from {from_peer}: {text}"
|
||||
self._registered = bool(stats.get("registered", 0))
|
||||
|
||||
def session_stats(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
session = self._session
|
||||
if session is None:
|
||||
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
|
||||
try:
|
||||
return dict(session.stats())
|
||||
except Exception:
|
||||
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
|
||||
|
||||
def session_kcp_stats(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
session = self._session
|
||||
return safe_kcp_stats(session)
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
config = load_omnisocket_config()
|
||||
control_cfg = config.get("control_sender", {})
|
||||
session_stats = self.session_stats()
|
||||
with self._lock:
|
||||
return {
|
||||
"backend_ready": self._session_cls is not None,
|
||||
"started": self._started,
|
||||
"connected": self._session is not None,
|
||||
"registered": bool(session_stats.get("registered", 0)),
|
||||
"peer_id": str(control_cfg.get("peer_id", "")),
|
||||
"target_peer": str(control_cfg.get("target_peer", "")),
|
||||
"send_count": self._send_count,
|
||||
"send_errors": self._send_errors,
|
||||
"drain_errors": self._drain_errors,
|
||||
"reconnect_count": self._reconnect_count,
|
||||
"last_server_error": str(session_stats.get("last_server_error", "") or ""),
|
||||
"last_error": self._last_error,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._closing.set()
|
||||
self.send_zero_burst(1)
|
||||
self._reset_session(None)
|
||||
drain_thread = self._drain_thread
|
||||
if drain_thread is not None and drain_thread.is_alive():
|
||||
drain_thread.join(timeout=0.5)
|
||||
|
||||
|
||||
class ControlArbiter:
|
||||
def __init__(self, sender: OmniSocketControlSender) -> None:
|
||||
self._sender = sender
|
||||
self._lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._closing = threading.Event()
|
||||
self._started = False
|
||||
self._source_lease_ms = 300
|
||||
self._send_rate_hz = 20.0
|
||||
self._zero_burst_packets = 3
|
||||
self._latest_by_source: dict[str, tuple[bytes, float]] = {}
|
||||
self._packet_counts = {source: 0 for source in CONTROL_SOURCE_PRIORITY}
|
||||
self._last_payload = ZERO_CONTROL_PAYLOAD
|
||||
self._last_sent_at = 0.0
|
||||
self._active_source: str | None = None
|
||||
self._last_error = ""
|
||||
|
||||
def _load_config(self) -> None:
|
||||
cfg = load_omnisocket_config().get("control_ingress", {})
|
||||
self._source_lease_ms = max(50, int(cfg.get("source_lease_ms", 300)))
|
||||
self._send_rate_hz = max(1.0, float(cfg.get("send_rate_hz", 20.0)))
|
||||
self._zero_burst_packets = max(1, int(cfg.get("zero_burst_packets", 3)))
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
self._load_config()
|
||||
with self._lock:
|
||||
if self._closing.is_set():
|
||||
return
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._send_loop,
|
||||
name="control-arbiter",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def ingest_command(self, source: str, payload: bytes) -> None:
|
||||
if source not in CONTROL_SOURCE_PRIORITY:
|
||||
raise ValueError(f"unsupported control source: {source}")
|
||||
if len(payload) != CONTROL_PACKET_SIZE:
|
||||
raise ValueError(f"expected {CONTROL_PACKET_SIZE} bytes, got {len(payload)}")
|
||||
|
||||
self.ensure_started()
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._latest_by_source[source] = (payload, now)
|
||||
self._packet_counts[source] += 1
|
||||
|
||||
def _resolve_active_locked(self, now: float) -> tuple[str | None, bytes, int]:
|
||||
lease_seconds = self._source_lease_ms / 1000.0
|
||||
expired_sources = [
|
||||
source
|
||||
for source, (_, updated_at) in self._latest_by_source.items()
|
||||
if (now - updated_at) > lease_seconds
|
||||
]
|
||||
for source in expired_sources:
|
||||
self._latest_by_source.pop(source, None)
|
||||
|
||||
for source in CONTROL_SOURCE_PRIORITY:
|
||||
entry = self._latest_by_source.get(source)
|
||||
if entry is None:
|
||||
continue
|
||||
payload, updated_at = entry
|
||||
remaining_ms = max(0, int((lease_seconds - (now - updated_at)) * 1000))
|
||||
return source, payload, remaining_ms
|
||||
|
||||
return None, ZERO_CONTROL_PAYLOAD, 0
|
||||
|
||||
def _send_loop(self) -> None:
|
||||
interval = 1.0 / max(self._send_rate_hz, 1.0)
|
||||
previous_active: str | None = None
|
||||
|
||||
while not self._closing.is_set():
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
active_source, payload, _lease_ms = self._resolve_active_locked(now)
|
||||
self._active_source = active_source
|
||||
self._last_payload = payload
|
||||
|
||||
if previous_active is not None and active_source is None:
|
||||
try:
|
||||
self._sender.send_zero_burst(self._zero_burst_packets)
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
self._last_error = str(error)
|
||||
elif active_source is not None:
|
||||
try:
|
||||
self._sender.send_payload(payload)
|
||||
with self._lock:
|
||||
self._last_sent_at = time.monotonic()
|
||||
self._last_error = ""
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
self._last_error = str(error)
|
||||
|
||||
previous_active = active_source
|
||||
self._closing.wait(interval)
|
||||
|
||||
try:
|
||||
self._sender.send_zero_burst(self._zero_burst_packets)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
self.ensure_started()
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
active_source, _payload, lease_ms = self._resolve_active_locked(now)
|
||||
return {
|
||||
"active_source": active_source,
|
||||
"control_lease_remaining_ms": lease_ms,
|
||||
"packet_counts": dict(self._packet_counts),
|
||||
"send_rate_hz": self._send_rate_hz,
|
||||
"source_lease_ms": self._source_lease_ms,
|
||||
"zero_burst_packets": self._zero_burst_packets,
|
||||
"last_error": self._last_error,
|
||||
"last_sent_at_monotonic": self._last_sent_at,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._closing.set()
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=0.5)
|
||||
|
||||
|
||||
class NativeUdpControlIngress:
|
||||
def __init__(self, arbiter: ControlArbiter) -> None:
|
||||
self._arbiter = arbiter
|
||||
self._lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._closing = threading.Event()
|
||||
self._started = False
|
||||
self._bind_addr = "127.0.0.1:10921"
|
||||
self._packets_received = 0
|
||||
self._invalid_packets = 0
|
||||
self._last_sender = ""
|
||||
self._last_error = ""
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
bind_addr = str(load_omnisocket_config().get("control_ingress", {}).get("native_udp_bind", "127.0.0.1:10921"))
|
||||
with self._lock:
|
||||
self._bind_addr = bind_addr
|
||||
if self._closing.is_set():
|
||||
return
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._started = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="native-udp-control-ingress",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
try:
|
||||
host, port = parse_host_port(self._bind_addr)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((host, port))
|
||||
sock.settimeout(0.1)
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
self._last_error = str(error)
|
||||
return
|
||||
|
||||
with sock:
|
||||
while not self._closing.is_set():
|
||||
try:
|
||||
payload, sender_addr = sock.recvfrom(CONTROL_PACKET_SIZE + 64)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError as error:
|
||||
with self._lock:
|
||||
if not self._closing.is_set():
|
||||
self._last_error = str(error)
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._last_sender = f"{sender_addr[0]}:{sender_addr[1]}"
|
||||
|
||||
if len(payload) != CONTROL_PACKET_SIZE:
|
||||
with self._lock:
|
||||
self._invalid_packets += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
self._arbiter.ingest_command(CONTROL_SOURCE_NATIVE_UDP, payload)
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
self._last_error = str(error)
|
||||
continue
|
||||
|
||||
with self._lock:
|
||||
self._packets_received += 1
|
||||
finally:
|
||||
with self._lock:
|
||||
self._started = False
|
||||
self._thread = None
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
self.ensure_started()
|
||||
with self._lock:
|
||||
return {
|
||||
"started": self._started,
|
||||
"bind_addr": self._bind_addr,
|
||||
"packets_received": self._packets_received,
|
||||
"invalid_packets": self._invalid_packets,
|
||||
"last_sender": self._last_sender,
|
||||
"last_error": self._last_error,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._closing.set()
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=0.5)
|
||||
9
backend/monitoring/routing.py
Normal file
9
backend/monitoring/routing.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import re_path
|
||||
|
||||
from .consumers import ControlConsumer
|
||||
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"^ws/control/$", ControlConsumer.as_asgi()),
|
||||
]
|
||||
|
||||
@@ -1,212 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
import atexit
|
||||
|
||||
from .control import ControlArbiter, NativeUdpControlIngress, OmniSocketControlSender
|
||||
from .telemetry import GpsDataService, HubTelemetryReceiver, NetworkTelemetryService
|
||||
from .video import OmniSocketVideoReceiver, VideoFrameService
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKSPACE_ROOT = PROJECT_ROOT.parent
|
||||
GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
|
||||
GEOSTREAM_STALE_SECONDS = 15
|
||||
DAEMON_FRAME_URI = "omni-daemon://latest-frame"
|
||||
_video_receiver = OmniSocketVideoReceiver()
|
||||
_control_sender = OmniSocketControlSender()
|
||||
_hub_telemetry_receiver = HubTelemetryReceiver()
|
||||
|
||||
control_arbiter = ControlArbiter(_control_sender)
|
||||
native_control_ingress = NativeUdpControlIngress(control_arbiter)
|
||||
|
||||
def utc_iso_now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _load_daemon_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_daemon_client_api()
|
||||
_daemon_client = _OmniDaemonClient()
|
||||
|
||||
|
||||
def get_daemon_client():
|
||||
return _daemon_client
|
||||
|
||||
|
||||
def _default_receiver(error_message: str) -> dict[str, Any]:
|
||||
return {
|
||||
"backend_ready": False,
|
||||
"mode": "daemon",
|
||||
"connected": False,
|
||||
"has_recent_frame": False,
|
||||
"frames_received": 0,
|
||||
"latest_sequence": None,
|
||||
"last_error": error_message,
|
||||
"config_path": "",
|
||||
"server_addr": "",
|
||||
"relay_via": "",
|
||||
"peer_id": "",
|
||||
"buffer_bytes": 0,
|
||||
}
|
||||
|
||||
|
||||
class VideoFrameService:
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
try:
|
||||
state = get_daemon_client().get_state()
|
||||
except OmniDaemonError as error:
|
||||
return {
|
||||
"available": False,
|
||||
"source_mode": "daemon-unavailable",
|
||||
"frame_count": 0,
|
||||
"fps": 5,
|
||||
"frame_dir": DAEMON_FRAME_URI,
|
||||
"source_detail": str(error),
|
||||
"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:
|
||||
try:
|
||||
return get_daemon_client().get_video_frame()
|
||||
except OmniDaemonError as error:
|
||||
raise RuntimeError(str(error)) from error
|
||||
|
||||
def iter_mjpeg(self, fps: float = 6.0) -> Iterator[bytes]:
|
||||
frame_interval = 1.0 / max(1.0, min(fps, 30.0))
|
||||
|
||||
while True:
|
||||
frame = self.get_next_frame()
|
||||
header = (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
||||
)
|
||||
yield header + frame + b"\r\n"
|
||||
time.sleep(frame_interval)
|
||||
|
||||
|
||||
class GpsDataService:
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
payload = self._read_geostream_payload()
|
||||
if payload is not None:
|
||||
payload["source_mode"] = "geostream-json"
|
||||
payload["updated_at"] = utc_iso_now()
|
||||
return payload
|
||||
return self._build_simulated_payload()
|
||||
|
||||
def _read_geostream_payload(self) -> dict[str, Any] | None:
|
||||
if not GEOSTREAM_JSON_PATH.exists():
|
||||
return None
|
||||
|
||||
age_seconds = time.time() - GEOSTREAM_JSON_PATH.stat().st_mtime
|
||||
if age_seconds > GEOSTREAM_STALE_SECONDS:
|
||||
return None
|
||||
|
||||
try:
|
||||
with GEOSTREAM_JSON_PATH.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def _build_simulated_payload(self) -> dict[str, Any]:
|
||||
tick = time.time() / 12.0
|
||||
latitude = 31.2304 + math.sin(tick) * 0.0014
|
||||
longitude = 121.4737 + math.cos(tick) * 0.0018
|
||||
|
||||
return {
|
||||
"has_fix": True,
|
||||
"utc_time": datetime.now(UTC).strftime("%H:%M:%S"),
|
||||
"latitude": round(latitude, 6),
|
||||
"longitude": round(longitude, 6),
|
||||
"satellites": 14 + int((math.sin(tick * 0.7) + 1.0) * 2),
|
||||
"altitude_m": round(6.5 + math.cos(tick * 0.5) * 1.2, 2),
|
||||
"coordinate_system": "WGS84",
|
||||
"source_sentence": "SIMULATED",
|
||||
"raw_coordinate_format": "decimal degrees",
|
||||
"source_mode": "simulated",
|
||||
"updated_at": utc_iso_now(),
|
||||
}
|
||||
|
||||
|
||||
class NetworkTelemetryService:
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
try:
|
||||
state = get_daemon_client().get_state()
|
||||
except OmniDaemonError as error:
|
||||
return {
|
||||
"peer_status": "offline",
|
||||
"latency_ms": 0.0,
|
||||
"jitter_ms": 0.0,
|
||||
"retrans_pct": 0.0,
|
||||
"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 {
|
||||
"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()),
|
||||
}
|
||||
|
||||
|
||||
video_service = VideoFrameService()
|
||||
video_service = VideoFrameService(_video_receiver)
|
||||
gps_service = GpsDataService()
|
||||
network_service = NetworkTelemetryService()
|
||||
network_service = NetworkTelemetryService(
|
||||
_video_receiver,
|
||||
_control_sender,
|
||||
control_arbiter,
|
||||
native_control_ingress,
|
||||
_hub_telemetry_receiver,
|
||||
)
|
||||
|
||||
|
||||
def shutdown_monitoring_services() -> None:
|
||||
for closer in (
|
||||
network_service.close,
|
||||
native_control_ingress.close,
|
||||
control_arbiter.close,
|
||||
_hub_telemetry_receiver.close,
|
||||
_video_receiver.close,
|
||||
_control_sender.close,
|
||||
):
|
||||
try:
|
||||
closer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(shutdown_monitoring_services)
|
||||
|
||||
|
||||
674
backend/monitoring/telemetry.py
Normal file
674
backend/monitoring/telemetry.py
Normal file
@@ -0,0 +1,674 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .common import (
|
||||
GEOSTREAM_JSON_PATH,
|
||||
GEOSTREAM_STALE_SECONDS,
|
||||
WORKSPACE_ROOT,
|
||||
load_omnisocket_config,
|
||||
utc_iso_now,
|
||||
)
|
||||
from .control import ControlArbiter, NativeUdpControlIngress, OmniSocketControlSender
|
||||
from .video import OmniSocketVideoReceiver
|
||||
|
||||
|
||||
LOCAL_SAMPLE_INTERVAL_MS = 500
|
||||
TREND_HISTORY_SIZE = 10
|
||||
TREND_WINDOW_SIZE = 5
|
||||
|
||||
|
||||
def _utc_from_epoch(epoch_seconds: float | None) -> str | None:
|
||||
if epoch_seconds is None or epoch_seconds <= 0.0:
|
||||
return None
|
||||
return datetime.fromtimestamp(epoch_seconds, timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class GpsDataService:
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
payload = self._read_geostream_payload()
|
||||
if payload is not None:
|
||||
payload["source_mode"] = "geostream-json"
|
||||
payload["updated_at"] = utc_iso_now()
|
||||
return payload
|
||||
|
||||
return self._build_simulated_payload()
|
||||
|
||||
def _read_geostream_payload(self) -> dict[str, Any] | None:
|
||||
if not GEOSTREAM_JSON_PATH.exists():
|
||||
return None
|
||||
|
||||
age_seconds = time.time() - GEOSTREAM_JSON_PATH.stat().st_mtime
|
||||
if age_seconds > GEOSTREAM_STALE_SECONDS:
|
||||
return None
|
||||
|
||||
try:
|
||||
with GEOSTREAM_JSON_PATH.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def _build_simulated_payload(self) -> dict[str, Any]:
|
||||
tick = time.time() / 12.0
|
||||
latitude = 31.2304 + math.sin(tick) * 0.0014
|
||||
longitude = 121.4737 + math.cos(tick) * 0.0018
|
||||
|
||||
return {
|
||||
"has_fix": True,
|
||||
"utc_time": datetime.now(timezone.utc).strftime("%H:%M:%S"),
|
||||
"latitude": round(latitude, 6),
|
||||
"longitude": round(longitude, 6),
|
||||
"satellites": 14 + int((math.sin(tick * 0.7) + 1.0) * 2),
|
||||
"altitude_m": round(6.5 + math.cos(tick * 0.5) * 1.2, 2),
|
||||
"coordinate_system": "WGS84",
|
||||
"source_sentence": "SIMULATED",
|
||||
"raw_coordinate_format": "decimal degrees",
|
||||
"source_mode": "simulated",
|
||||
"updated_at": utc_iso_now(),
|
||||
}
|
||||
|
||||
|
||||
class KcpTrendTracker:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._samples: dict[str, deque[dict[str, Any]]] = {}
|
||||
|
||||
def _normalize(self, stats: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = dict(stats or {})
|
||||
snd_wnd = _coerce_int(raw.get("snd_wnd"))
|
||||
rmt_wnd = _coerce_int(raw.get("rmt_wnd"))
|
||||
inflight = _coerce_int(raw.get("inflight"))
|
||||
window_limit = _coerce_int(raw.get("window_limit"), min(snd_wnd, rmt_wnd) if snd_wnd and rmt_wnd else 0)
|
||||
return {
|
||||
"connected": _coerce_int(raw.get("connected")),
|
||||
"conv": _coerce_int(raw.get("conv")),
|
||||
"rto_ms": _coerce_int(raw.get("rto_ms")),
|
||||
"srtt_ms": _coerce_int(raw.get("srtt_ms")),
|
||||
"srttvar_ms": _coerce_int(raw.get("srttvar_ms")),
|
||||
"snd_wnd": snd_wnd,
|
||||
"rmt_wnd": rmt_wnd,
|
||||
"inflight": inflight,
|
||||
"window_limit": window_limit,
|
||||
"window_pressure_pct": round(_coerce_float(raw.get("window_pressure_pct")), 3),
|
||||
"snd_queue": _coerce_int(raw.get("snd_queue")),
|
||||
"rcv_queue": _coerce_int(raw.get("rcv_queue")),
|
||||
"snd_buffer": _coerce_int(raw.get("snd_buffer")),
|
||||
"out_segs_total": _coerce_int(raw.get("out_segs_total")),
|
||||
"retrans_total": _coerce_int(raw.get("retrans_total")),
|
||||
"fast_retrans_total": _coerce_int(raw.get("fast_retrans_total")),
|
||||
"lost_total": _coerce_int(raw.get("lost_total")),
|
||||
"repeat_total": _coerce_int(raw.get("repeat_total")),
|
||||
"xmit_total": _coerce_int(raw.get("xmit_total")),
|
||||
}
|
||||
|
||||
def add_sample(self, key: str, stats: dict[str, Any] | None) -> None:
|
||||
sample = {
|
||||
"ts_monotonic": time.monotonic(),
|
||||
"updated_at": utc_iso_now(),
|
||||
"stats": self._normalize(stats),
|
||||
}
|
||||
with self._lock:
|
||||
history = self._samples.setdefault(key, deque(maxlen=TREND_HISTORY_SIZE))
|
||||
history.append(sample)
|
||||
|
||||
def latest_updated_at(self, key: str) -> str | None:
|
||||
with self._lock:
|
||||
history = self._samples.get(key)
|
||||
if not history:
|
||||
return None
|
||||
return str(history[-1].get("updated_at") or "")
|
||||
|
||||
def describe(self, key: str, current_stats: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = self._normalize(current_stats)
|
||||
with self._lock:
|
||||
history = list(self._samples.get(key, ()))
|
||||
|
||||
timeline = history + [{"stats": current, "updated_at": utc_iso_now()}]
|
||||
previous = timeline[-2]["stats"] if len(timeline) >= 2 else None
|
||||
trend_window = [entry["stats"] for entry in timeline[-TREND_WINDOW_SIZE:]]
|
||||
deadband = max(2.0, 0.05 * float(max(current.get("window_limit", 0), 1)))
|
||||
|
||||
snd_queue_delta = 0
|
||||
snd_buffer_delta = 0
|
||||
retrans_delta = 0
|
||||
fast_retrans_delta = 0
|
||||
lost_delta = 0
|
||||
repeat_delta = 0
|
||||
out_segs_delta = 0
|
||||
if previous is not None:
|
||||
snd_queue_delta = max(0, current["snd_queue"] - _coerce_int(previous.get("snd_queue")))
|
||||
snd_buffer_delta = max(0, current["snd_buffer"] - _coerce_int(previous.get("snd_buffer")))
|
||||
retrans_delta = max(0, current["retrans_total"] - _coerce_int(previous.get("retrans_total")))
|
||||
fast_retrans_delta = max(0, current["fast_retrans_total"] - _coerce_int(previous.get("fast_retrans_total")))
|
||||
lost_delta = max(0, current["lost_total"] - _coerce_int(previous.get("lost_total")))
|
||||
repeat_delta = max(0, current["repeat_total"] - _coerce_int(previous.get("repeat_total")))
|
||||
out_segs_delta = max(0, current["out_segs_total"] - _coerce_int(previous.get("out_segs_total")))
|
||||
|
||||
def classify(field: str) -> str:
|
||||
if len(trend_window) < 2:
|
||||
return "stable"
|
||||
oldest = float(_coerce_int(trend_window[0].get(field)))
|
||||
newest = float(_coerce_int(trend_window[-1].get(field)))
|
||||
delta = newest - oldest
|
||||
if abs(delta) < deadband:
|
||||
return "stable"
|
||||
return "rising" if delta > 0 else "falling"
|
||||
|
||||
repair_rate_pct = 0.0
|
||||
if out_segs_delta > 0:
|
||||
repair_rate_pct = round((retrans_delta / out_segs_delta) * 100.0, 3)
|
||||
|
||||
return {
|
||||
"kcp": current,
|
||||
"trend": {
|
||||
"snd_queue_delta": snd_queue_delta,
|
||||
"snd_buffer_delta": snd_buffer_delta,
|
||||
"snd_queue_trend": classify("snd_queue"),
|
||||
"snd_buffer_trend": classify("snd_buffer"),
|
||||
"retrans_delta": retrans_delta,
|
||||
"fast_retrans_delta": fast_retrans_delta,
|
||||
"lost_delta": lost_delta,
|
||||
"repeat_delta": repeat_delta,
|
||||
"out_segs_delta": out_segs_delta,
|
||||
"repair_rate_pct": repair_rate_pct,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HubTelemetryReceiver:
|
||||
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._msg_type_text = None
|
||||
self._msg_type_error = None
|
||||
self._telemetry_defaults: dict[str, Any] = {}
|
||||
self._latest_snapshot: dict[str, Any] | None = None
|
||||
self._last_error = ""
|
||||
self._last_received_wall = 0.0
|
||||
self._last_received_monotonic = 0.0
|
||||
self._reconnect_count = 0
|
||||
self._ever_connected = False
|
||||
self._closing = threading.Event()
|
||||
self._load_backend()
|
||||
|
||||
def _load_backend(self) -> None:
|
||||
try:
|
||||
self._import_backend()
|
||||
except Exception as error: # pragma: no cover - optional runtime dependency
|
||||
self._last_error = f"omnisocket import failed: {error}"
|
||||
|
||||
def _import_backend(self) -> None:
|
||||
try:
|
||||
from omnisocket import MSG_TYPE_ERROR, MSG_TYPE_TEXT, Session, TELEMETRY_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_ERROR, MSG_TYPE_TEXT, Session, TELEMETRY_DEFAULTS # type: ignore
|
||||
|
||||
self._msg_type_error = MSG_TYPE_ERROR
|
||||
self._msg_type_text = MSG_TYPE_TEXT
|
||||
self._session_cls = Session
|
||||
self._telemetry_defaults = dict(TELEMETRY_DEFAULTS)
|
||||
|
||||
def _connect_session(self):
|
||||
assert self._session_cls is not None
|
||||
|
||||
config = load_omnisocket_config()
|
||||
transport_cfg = config.get("transport", {})
|
||||
telemetry_cfg = config.get("telemetry_receiver", {})
|
||||
|
||||
session = self._session_cls()
|
||||
session.connect(
|
||||
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
|
||||
peer_id=str(telemetry_cfg.get("peer_id", "peer-a-telemetry")),
|
||||
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._telemetry_defaults,
|
||||
)
|
||||
return session
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
if self._session_cls is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if self._started or self._closing.is_set():
|
||||
return
|
||||
self._started = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="hub-telemetry-receiver",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._closing.is_set():
|
||||
try:
|
||||
session = self._connect_session()
|
||||
with self._lock:
|
||||
self._session = session
|
||||
self._last_error = ""
|
||||
if self._ever_connected:
|
||||
self._reconnect_count += 1
|
||||
else:
|
||||
self._ever_connected = True
|
||||
|
||||
while not self._closing.is_set():
|
||||
result = session.recv(timeout_ms=1000)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
from_peer, msg_type, payload = result
|
||||
if msg_type == self._msg_type_error:
|
||||
with self._lock:
|
||||
self._last_error = f"hub error from {from_peer}: {payload.decode('utf-8', errors='replace')}"
|
||||
continue
|
||||
if msg_type != self._msg_type_text:
|
||||
continue
|
||||
|
||||
snapshot = json.loads(payload.decode("utf-8"))
|
||||
if snapshot.get("type") != "hub_kcp_snapshot":
|
||||
continue
|
||||
|
||||
now_wall = time.time()
|
||||
now_mono = time.monotonic()
|
||||
with self._lock:
|
||||
self._latest_snapshot = snapshot
|
||||
self._last_received_wall = now_wall
|
||||
self._last_received_monotonic = now_mono
|
||||
self._last_error = ""
|
||||
except Exception as error: # pragma: no cover - runtime integration path
|
||||
if not self._closing.is_set():
|
||||
session_error = ""
|
||||
if self._session is not None:
|
||||
try:
|
||||
session_error = str(dict(self._session.stats()).get("last_server_error", "") or "")
|
||||
except Exception:
|
||||
session_error = ""
|
||||
with self._lock:
|
||||
self._last_error = session_error or str(error)
|
||||
finally:
|
||||
with self._lock:
|
||||
session = self._session
|
||||
self._session = None
|
||||
if self._closing.is_set():
|
||||
self._started = False
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not self._closing.is_set():
|
||||
time.sleep(2)
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
self.ensure_started()
|
||||
cfg = load_omnisocket_config().get("telemetry_receiver", {})
|
||||
stale_after_ms = max(500, int(cfg.get("stale_after_ms", 1500)))
|
||||
|
||||
with self._lock:
|
||||
received_monotonic = self._last_received_monotonic
|
||||
received_wall = self._last_received_wall
|
||||
snapshot = self._latest_snapshot
|
||||
connected = self._session is not None
|
||||
last_error = self._last_error
|
||||
reconnect_count = self._reconnect_count
|
||||
if self._session is not None:
|
||||
try:
|
||||
session_stats = dict(self._session.stats())
|
||||
except Exception:
|
||||
session_stats = {}
|
||||
else:
|
||||
session_stats = {}
|
||||
|
||||
stale = True
|
||||
if received_monotonic > 0.0:
|
||||
stale = (time.monotonic() - received_monotonic) * 1000.0 > stale_after_ms
|
||||
|
||||
return {
|
||||
"connected": connected,
|
||||
"updated_at": _utc_from_epoch(received_wall),
|
||||
"received_at_monotonic": received_monotonic,
|
||||
"stale": stale,
|
||||
"peer_id": str(cfg.get("peer_id", "peer-a-telemetry")),
|
||||
"snapshot": snapshot or {"sessions": []},
|
||||
"last_error": last_error,
|
||||
"registered": bool(session_stats.get("registered", 0)),
|
||||
"last_server_error": str(session_stats.get("last_server_error", "") or ""),
|
||||
"reconnect_count": reconnect_count,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._closing.set()
|
||||
with self._lock:
|
||||
session = self._session
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=0.5)
|
||||
|
||||
|
||||
class NetworkTelemetryService:
|
||||
def __init__(
|
||||
self,
|
||||
video_receiver: OmniSocketVideoReceiver,
|
||||
control_sender: OmniSocketControlSender,
|
||||
control_arbiter: ControlArbiter,
|
||||
native_ingress: NativeUdpControlIngress,
|
||||
hub_receiver: HubTelemetryReceiver,
|
||||
) -> None:
|
||||
self._video_receiver = video_receiver
|
||||
self._control_sender = control_sender
|
||||
self._control_arbiter = control_arbiter
|
||||
self._native_ingress = native_ingress
|
||||
self._hub_receiver = hub_receiver
|
||||
self._trend_tracker = KcpTrendTracker()
|
||||
self._rate_lock = threading.Lock()
|
||||
self._last_rate_sample: tuple[float, int, int] | None = None
|
||||
self._sample_thread: threading.Thread | None = None
|
||||
self._sample_started = False
|
||||
self._last_remote_snapshot_at = 0.0
|
||||
self._closing = threading.Event()
|
||||
|
||||
def _ensure_started(self) -> None:
|
||||
self._video_receiver.ensure_started()
|
||||
self._control_arbiter.ensure_started()
|
||||
self._native_ingress.ensure_started()
|
||||
self._hub_receiver.ensure_started()
|
||||
with self._rate_lock:
|
||||
if self._sample_started or self._closing.is_set():
|
||||
return
|
||||
self._sample_started = True
|
||||
self._sample_thread = threading.Thread(
|
||||
target=self._sample_loop,
|
||||
name="network-telemetry-sampler",
|
||||
daemon=True,
|
||||
)
|
||||
self._sample_thread.start()
|
||||
|
||||
def _sample_loop(self) -> None:
|
||||
interval_seconds = LOCAL_SAMPLE_INTERVAL_MS / 1000.0
|
||||
while not self._closing.is_set():
|
||||
try:
|
||||
self._trend_tracker.add_sample("a_to_d.video", self._video_receiver.session_kcp_stats())
|
||||
self._trend_tracker.add_sample("a_to_d.control", self._control_sender.session_kcp_stats())
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval_seconds)
|
||||
|
||||
def _compute_rates(self, send_bytes: int, recv_bytes: int) -> tuple[float, float]:
|
||||
now = time.monotonic()
|
||||
with self._rate_lock:
|
||||
previous = self._last_rate_sample
|
||||
self._last_rate_sample = (now, send_bytes, recv_bytes)
|
||||
|
||||
if previous is None:
|
||||
return 0.0, 0.0
|
||||
|
||||
prev_time, prev_send, prev_recv = previous
|
||||
elapsed = now - prev_time
|
||||
if elapsed <= 0.0:
|
||||
return 0.0, 0.0
|
||||
|
||||
tx_kbps = max(0.0, ((send_bytes - prev_send) * 8.0) / elapsed / 1000.0)
|
||||
rx_kbps = max(0.0, ((recv_bytes - prev_recv) * 8.0) / elapsed / 1000.0)
|
||||
return tx_kbps, rx_kbps
|
||||
|
||||
def _ingest_remote_snapshot(self, telemetry_state: dict[str, Any]) -> None:
|
||||
received_at = float(telemetry_state.get("received_at_monotonic") or 0.0)
|
||||
if received_at <= 0.0 or received_at <= self._last_remote_snapshot_at:
|
||||
return
|
||||
|
||||
snapshot = telemetry_state.get("snapshot") or {}
|
||||
sessions = snapshot.get("sessions") or []
|
||||
for session in sessions:
|
||||
peer_id = str(session.get("peer_id", "")).strip()
|
||||
if not peer_id:
|
||||
continue
|
||||
self._trend_tracker.add_sample(f"hub::{peer_id}", session)
|
||||
self._last_remote_snapshot_at = received_at
|
||||
|
||||
def _build_session_payload(
|
||||
self,
|
||||
trend_key: str,
|
||||
peer_id: str,
|
||||
app_stats: dict[str, Any] | None,
|
||||
current_kcp: dict[str, Any] | None,
|
||||
updated_at: str | None,
|
||||
stale: bool,
|
||||
) -> dict[str, Any]:
|
||||
described = self._trend_tracker.describe(trend_key, current_kcp)
|
||||
connected = bool(described["kcp"].get("connected"))
|
||||
if app_stats is not None and "registered" in app_stats:
|
||||
connected = bool(app_stats.get("registered"))
|
||||
return {
|
||||
"peer_id": peer_id,
|
||||
"connected": connected,
|
||||
"updated_at": updated_at,
|
||||
"stale": stale,
|
||||
"app": app_stats,
|
||||
"kcp": described["kcp"],
|
||||
"trend": described["trend"],
|
||||
}
|
||||
|
||||
def _build_link(self, source: str, updated_at: str | None, stale: bool, sessions: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
session_items = list(sessions.values())
|
||||
active_sessions = [session for session in session_items if session.get("connected") and not session.get("stale")]
|
||||
retrans_sum = sum(_coerce_int(session.get("trend", {}).get("retrans_delta")) for session in active_sessions)
|
||||
out_segs_sum = sum(_coerce_int(session.get("trend", {}).get("out_segs_delta")) for session in active_sessions)
|
||||
repair_rate_pct = round((retrans_sum / out_segs_sum) * 100.0, 3) if out_segs_sum > 0 else 0.0
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"updated_at": updated_at,
|
||||
"stale": stale,
|
||||
"aggregate": {
|
||||
"online_sessions": len(active_sessions),
|
||||
"max_window_pressure_pct": max(
|
||||
(_coerce_float(session.get("kcp", {}).get("window_pressure_pct")) for session in active_sessions),
|
||||
default=0.0,
|
||||
),
|
||||
"sum_snd_queue": sum(_coerce_int(session.get("kcp", {}).get("snd_queue")) for session in active_sessions),
|
||||
"sum_snd_buffer": sum(_coerce_int(session.get("kcp", {}).get("snd_buffer")) for session in active_sessions),
|
||||
"sum_retrans_delta": retrans_sum,
|
||||
"sum_out_segs_delta": out_segs_sum,
|
||||
"repair_rate_pct": repair_rate_pct,
|
||||
},
|
||||
"sessions": sessions,
|
||||
}
|
||||
|
||||
def _pick_primary_session(self, links: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
|
||||
candidates = (
|
||||
links["a_to_d"]["sessions"]["control"],
|
||||
links["a_to_d"]["sessions"]["video"],
|
||||
links["d_to_b"]["sessions"]["control"],
|
||||
links["d_to_b"]["sessions"]["video"],
|
||||
)
|
||||
for session in candidates:
|
||||
if session.get("connected") and not session.get("stale"):
|
||||
return session
|
||||
return None
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
self._ensure_started()
|
||||
|
||||
config = load_omnisocket_config()
|
||||
video_receiver_cfg = config.get("video_receiver", {})
|
||||
control_sender_cfg = config.get("control_sender", {})
|
||||
video_sender_cfg = config.get("video_sender", {})
|
||||
|
||||
video_app = self._video_receiver.session_stats()
|
||||
control_app = self._control_sender.session_stats()
|
||||
video_kcp = self._video_receiver.session_kcp_stats()
|
||||
control_kcp = self._control_sender.session_kcp_stats()
|
||||
arbiter_status = self._control_arbiter.get_status()
|
||||
ingress_status = self._native_ingress.get_status()
|
||||
sender_status = self._control_sender.get_status()
|
||||
telemetry_state = self._hub_receiver.get_snapshot()
|
||||
|
||||
total_send_bytes = int(video_app.get("send_bytes", 0)) + int(control_app.get("send_bytes", 0))
|
||||
total_recv_bytes = int(video_app.get("recv_bytes", 0)) + int(control_app.get("recv_bytes", 0))
|
||||
tx_kbps, rx_kbps = self._compute_rates(total_send_bytes, total_recv_bytes)
|
||||
|
||||
local_updated_at = utc_iso_now()
|
||||
local_sessions = {
|
||||
"video": self._build_session_payload(
|
||||
"a_to_d.video",
|
||||
str(video_receiver_cfg.get("peer_id", "peer-a-video")),
|
||||
video_app,
|
||||
video_kcp,
|
||||
local_updated_at,
|
||||
False,
|
||||
),
|
||||
"control": self._build_session_payload(
|
||||
"a_to_d.control",
|
||||
str(control_sender_cfg.get("peer_id", "peer-a-ctrl")),
|
||||
control_app,
|
||||
control_kcp,
|
||||
local_updated_at,
|
||||
False,
|
||||
),
|
||||
}
|
||||
|
||||
remote_snapshot = telemetry_state.get("snapshot") or {}
|
||||
remote_sessions_by_peer = {
|
||||
str(session.get("peer_id", "")).strip(): session
|
||||
for session in remote_snapshot.get("sessions", []) or []
|
||||
if str(session.get("peer_id", "")).strip()
|
||||
}
|
||||
remote_updated_at = telemetry_state.get("updated_at")
|
||||
remote_stale = bool(telemetry_state.get("stale", True))
|
||||
remote_sessions = {
|
||||
"video": self._build_session_payload(
|
||||
f"hub::{str(video_sender_cfg.get('peer_id', 'peer-b-video'))}",
|
||||
str(video_sender_cfg.get("peer_id", "peer-b-video")),
|
||||
None,
|
||||
remote_sessions_by_peer.get(str(video_sender_cfg.get("peer_id", "peer-b-video")), {}),
|
||||
remote_updated_at,
|
||||
remote_stale,
|
||||
),
|
||||
"control": self._build_session_payload(
|
||||
f"hub::{str(control_sender_cfg.get('target_peer', 'peer-b-ctrl'))}",
|
||||
str(control_sender_cfg.get("target_peer", "peer-b-ctrl")),
|
||||
None,
|
||||
remote_sessions_by_peer.get(str(control_sender_cfg.get("target_peer", "peer-b-ctrl")), {}),
|
||||
remote_updated_at,
|
||||
remote_stale,
|
||||
),
|
||||
}
|
||||
|
||||
links = {
|
||||
"a_to_d": self._build_link("local-a-side", local_updated_at, False, local_sessions),
|
||||
"d_to_b": self._build_link("hub-telemetry", remote_updated_at, remote_stale, remote_sessions),
|
||||
}
|
||||
|
||||
primary_session = self._pick_primary_session(links)
|
||||
primary_kcp = dict(primary_session.get("kcp", {})) if primary_session is not None else {}
|
||||
self._ingest_remote_snapshot(telemetry_state)
|
||||
|
||||
fresh_connected_sessions = (
|
||||
links["a_to_d"]["aggregate"]["online_sessions"] + links["d_to_b"]["aggregate"]["online_sessions"]
|
||||
)
|
||||
latency_ms = primary_kcp.get("srtt_ms") if primary_session is not None else None
|
||||
jitter_ms = primary_kcp.get("srttvar_ms") if primary_session is not None else None
|
||||
local_control_registered = bool(control_app.get("registered", 0))
|
||||
remote_control_fresh = bool(remote_sessions["control"].get("connected")) and not bool(remote_sessions["control"].get("stale"))
|
||||
|
||||
if local_control_registered and remote_control_fresh:
|
||||
peer_status = "online"
|
||||
elif local_control_registered or bool(local_sessions["video"].get("connected")):
|
||||
peer_status = "degraded"
|
||||
elif sender_status.get("backend_ready"):
|
||||
peer_status = "idle"
|
||||
else:
|
||||
peer_status = "backend-unavailable"
|
||||
|
||||
return {
|
||||
"peer_status": peer_status,
|
||||
"latency_ms": latency_ms,
|
||||
"jitter_ms": jitter_ms,
|
||||
"packet_loss_pct": None,
|
||||
"tx_kbps": round(tx_kbps, 3),
|
||||
"rx_kbps": round(rx_kbps, 3),
|
||||
"transport": "OmniSocket / kcp",
|
||||
"source_mode": "omnisocket-live" if fresh_connected_sessions > 0 else "omnisocket-idle",
|
||||
"updated_at": utc_iso_now(),
|
||||
"active_control_source": arbiter_status["active_source"],
|
||||
"control_lease_remaining_ms": arbiter_status["control_lease_remaining_ms"],
|
||||
"combined": {
|
||||
"connected_sessions": fresh_connected_sessions,
|
||||
"send_bytes": total_send_bytes,
|
||||
"recv_bytes": total_recv_bytes,
|
||||
"tx_kbps": round(tx_kbps, 3),
|
||||
"rx_kbps": round(rx_kbps, 3),
|
||||
},
|
||||
"sessions": {
|
||||
"video": {
|
||||
"app": video_app,
|
||||
"kcp": local_sessions["video"]["kcp"],
|
||||
},
|
||||
"control": {
|
||||
"app": control_app,
|
||||
"kcp": local_sessions["control"]["kcp"],
|
||||
},
|
||||
},
|
||||
"links": links,
|
||||
"telemetry_receiver": {
|
||||
"hub_connected": bool(telemetry_state.get("connected")),
|
||||
"hub_updated_at": telemetry_state.get("updated_at"),
|
||||
"hub_stale": remote_stale,
|
||||
"last_error": telemetry_state.get("last_error", ""),
|
||||
"peer_id": telemetry_state.get("peer_id", ""),
|
||||
"registered": bool(telemetry_state.get("registered", False)),
|
||||
"last_server_error": str(telemetry_state.get("last_server_error", "") or ""),
|
||||
"reconnect_count": int(telemetry_state.get("reconnect_count", 0)),
|
||||
},
|
||||
"ingress": {
|
||||
"native_udp": ingress_status,
|
||||
},
|
||||
"control": {
|
||||
"arbiter": arbiter_status,
|
||||
"sender": sender_status,
|
||||
},
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._closing.set()
|
||||
thread = self._sample_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=0.5)
|
||||
378
backend/monitoring/video.py
Normal file
378
backend/monitoring/video.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .common import (
|
||||
JPEG_FRAME_DIR,
|
||||
OMNISOCKET_CONFIG_PATH,
|
||||
OMNISOCKET_FRAME_FRESH_SECONDS,
|
||||
PROJECT_ROOT,
|
||||
VIDEO_SOURCE_MODE,
|
||||
VIDEO_TIMESTAMP_ENDIANNESS,
|
||||
VIDEO_TIMESTAMP_MAX_SKEW_NS,
|
||||
VIDEO_TIMESTAMP_MULTIPLIER_NS,
|
||||
VIDEO_TIMESTAMP_SAMPLE_SIZE,
|
||||
VIDEO_TIMESTAMP_TRAILER_BYTES,
|
||||
VIDEO_TIMESTAMP_UNIT,
|
||||
WORKSPACE_ROOT,
|
||||
load_omnisocket_config,
|
||||
)
|
||||
|
||||
|
||||
def safe_kcp_stats(session: Any) -> dict[str, Any]:
|
||||
if session is None or not hasattr(session, "kcp_stats"):
|
||||
return {}
|
||||
try:
|
||||
return dict(session.kcp_stats())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class OmniSocketVideoReceiver:
|
||||
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._latest_latency_ms: float | None = None
|
||||
self._latest_timestamp_unit: str | None = None
|
||||
self._latest_timestamp_endianness: str | None = None
|
||||
self._latency_samples_ms: deque[float] = deque(maxlen=VIDEO_TIMESTAMP_SAMPLE_SIZE)
|
||||
self._frames_received = 0
|
||||
self._last_error = ""
|
||||
self._reconnect_count = 0
|
||||
self._ever_connected = False
|
||||
self._closing = threading.Event()
|
||||
self._load_backend()
|
||||
|
||||
def _load_backend(self) -> None:
|
||||
try:
|
||||
self._import_backend()
|
||||
except Exception as error: # pragma: no cover - optional runtime dependency
|
||||
self._last_error = f"omnisocket import failed: {error}"
|
||||
|
||||
def _import_backend(self) -> None:
|
||||
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 ensure_started(self) -> None:
|
||||
if self._session_cls is None or self._binary_msg_type is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if self._started or self._closing.is_set():
|
||||
return
|
||||
self._started = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="omnisocket-video-receiver",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _connect_session(self):
|
||||
assert self._session_cls is not None
|
||||
|
||||
config = load_omnisocket_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_payload(self, frame: bytes) -> bytes | None:
|
||||
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 _split_jpeg_frame_and_trailer(self, frame: bytes) -> tuple[bytes, bytes] | None:
|
||||
jpeg_payload = self._extract_jpeg_payload(frame)
|
||||
if jpeg_payload is None:
|
||||
return None
|
||||
|
||||
if jpeg_payload.endswith(b"\xff\xd9"):
|
||||
return jpeg_payload, b""
|
||||
|
||||
if (
|
||||
len(jpeg_payload) >= VIDEO_TIMESTAMP_TRAILER_BYTES + 2
|
||||
and jpeg_payload[-(VIDEO_TIMESTAMP_TRAILER_BYTES + 2) : -VIDEO_TIMESTAMP_TRAILER_BYTES] == b"\xff\xd9"
|
||||
):
|
||||
return jpeg_payload[:-VIDEO_TIMESTAMP_TRAILER_BYTES], jpeg_payload[-VIDEO_TIMESTAMP_TRAILER_BYTES:]
|
||||
|
||||
eoi_index = jpeg_payload.rfind(b"\xff\xd9")
|
||||
if eoi_index < 0:
|
||||
return jpeg_payload, b""
|
||||
|
||||
trailer_start = eoi_index + 2
|
||||
return jpeg_payload[:trailer_start], jpeg_payload[trailer_start:]
|
||||
|
||||
def _extract_jpeg_frame(self, frame: bytes) -> bytes | None:
|
||||
split_payload = self._split_jpeg_frame_and_trailer(frame)
|
||||
if split_payload is None:
|
||||
return None
|
||||
jpeg_frame, _ = split_payload
|
||||
return jpeg_frame
|
||||
|
||||
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 _extract_frame_tail(self, frame: bytes) -> bytes:
|
||||
split_payload = self._split_jpeg_frame_and_trailer(frame)
|
||||
if split_payload is None:
|
||||
return b""
|
||||
_, trailer = split_payload
|
||||
return trailer
|
||||
|
||||
def _extract_frame_timestamp(self, frame: bytes) -> tuple[int, str, str] | None:
|
||||
trailer = self._extract_frame_tail(frame)
|
||||
if len(trailer) != VIDEO_TIMESTAMP_TRAILER_BYTES:
|
||||
return None
|
||||
|
||||
value = int.from_bytes(trailer, VIDEO_TIMESTAMP_ENDIANNESS, signed=False)
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
timestamp_ns = value * VIDEO_TIMESTAMP_MULTIPLIER_NS
|
||||
if abs(time.time_ns() - timestamp_ns) > VIDEO_TIMESTAMP_MAX_SKEW_NS:
|
||||
return None
|
||||
|
||||
return timestamp_ns, VIDEO_TIMESTAMP_UNIT, VIDEO_TIMESTAMP_ENDIANNESS
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._closing.is_set():
|
||||
try:
|
||||
session, buffer_bytes = self._connect_session()
|
||||
with self._lock:
|
||||
self._session = session
|
||||
self._last_error = ""
|
||||
if self._ever_connected:
|
||||
self._reconnect_count += 1
|
||||
else:
|
||||
self._ever_connected = True
|
||||
buffer = bytearray(buffer_bytes)
|
||||
|
||||
while not self._closing.is_set():
|
||||
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
|
||||
|
||||
timestamp_meta = self._extract_frame_timestamp(frame)
|
||||
latency_ms = None
|
||||
if timestamp_meta is not None:
|
||||
timestamp_ns, unit, endianness = timestamp_meta
|
||||
latency_ms = round((time.time_ns() - timestamp_ns) / 1_000_000, 3)
|
||||
else:
|
||||
unit = None
|
||||
endianness = None
|
||||
|
||||
with self._lock:
|
||||
self._latest_frame = jpeg_frame
|
||||
self._latest_received_at = time.time()
|
||||
self._latest_sequence = self._extract_sequence(frame)
|
||||
self._latest_latency_ms = latency_ms
|
||||
self._latest_timestamp_unit = unit
|
||||
self._latest_timestamp_endianness = endianness
|
||||
if latency_ms is not None:
|
||||
self._latency_samples_ms.append(latency_ms)
|
||||
self._frames_received += 1
|
||||
except Exception as error: # pragma: no cover - runtime integration path
|
||||
if not self._closing.is_set():
|
||||
session_error = ""
|
||||
if self._session is not None:
|
||||
try:
|
||||
session_error = str(dict(self._session.stats()).get("last_server_error", "") or "")
|
||||
except Exception:
|
||||
session_error = ""
|
||||
self._last_error = session_error or str(error)
|
||||
time.sleep(2)
|
||||
finally:
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self._session = None
|
||||
if self._closing.is_set():
|
||||
self._started = False
|
||||
|
||||
def get_latest_frame(self) -> bytes | 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 session_stats(self) -> dict[str, Any]:
|
||||
self.ensure_started()
|
||||
with self._lock:
|
||||
session = self._session
|
||||
if session is None:
|
||||
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
|
||||
try:
|
||||
return dict(session.stats())
|
||||
except Exception:
|
||||
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
|
||||
|
||||
def session_kcp_stats(self) -> dict[str, Any]:
|
||||
self.ensure_started()
|
||||
with self._lock:
|
||||
session = self._session
|
||||
return safe_kcp_stats(session)
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
self.ensure_started()
|
||||
config = load_omnisocket_config()
|
||||
transport_cfg = config.get("transport", {})
|
||||
video_cfg = config.get("video_receiver", {})
|
||||
session_stats = self.session_stats()
|
||||
with self._lock:
|
||||
has_recent_frame = self._latest_frame is not None and (
|
||||
time.time() - self._latest_received_at <= OMNISOCKET_FRAME_FRESH_SECONDS
|
||||
)
|
||||
if has_recent_frame and self._latest_latency_ms is not None:
|
||||
timing_status = {
|
||||
"available": True,
|
||||
"latest_delta_ms": self._latest_latency_ms,
|
||||
"delta_samples_ms": list(reversed(self._latency_samples_ms)),
|
||||
"sample_count": len(self._latency_samples_ms),
|
||||
"sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE,
|
||||
"timestamp_unit": self._latest_timestamp_unit,
|
||||
"timestamp_endianness": self._latest_timestamp_endianness,
|
||||
}
|
||||
else:
|
||||
timing_status = {
|
||||
"available": False,
|
||||
"latest_delta_ms": None,
|
||||
"delta_samples_ms": [],
|
||||
"sample_count": 0,
|
||||
"sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE,
|
||||
"timestamp_unit": None,
|
||||
"timestamp_endianness": None,
|
||||
}
|
||||
return {
|
||||
"backend_ready": self._session_cls is not None,
|
||||
"mode": VIDEO_SOURCE_MODE,
|
||||
"connected": self._session is not None,
|
||||
"registered": bool(session_stats.get("registered", 0)),
|
||||
"has_recent_frame": has_recent_frame,
|
||||
"frames_received": self._frames_received,
|
||||
"latest_sequence": self._latest_sequence,
|
||||
"reconnect_count": self._reconnect_count,
|
||||
"last_server_error": str(session_stats.get("last_server_error", "") or ""),
|
||||
"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)),
|
||||
"timing": timing_status,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._closing.set()
|
||||
with self._lock:
|
||||
session = self._session
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=0.5)
|
||||
|
||||
|
||||
class VideoFrameService:
|
||||
def __init__(self, receiver: OmniSocketVideoReceiver) -> None:
|
||||
self._receiver = receiver
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
receiver_status = self._receiver.get_status()
|
||||
receiver_frame = self._receiver.get_latest_frame()
|
||||
|
||||
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,
|
||||
"timing": receiver_status["timing"],
|
||||
}
|
||||
|
||||
wait_detail = receiver_status["last_error"] or (
|
||||
"waiting for live OmniSocket JPEG frames; check the hub, sender, and receiver configuration"
|
||||
)
|
||||
return {
|
||||
"available": False,
|
||||
"source_mode": "omnisocket-waiting",
|
||||
"frame_count": receiver_status["frames_received"],
|
||||
"fps": 30,
|
||||
"frame_dir": str(JPEG_FRAME_DIR),
|
||||
"source_detail": wait_detail,
|
||||
"receiver": receiver_status,
|
||||
"timing": receiver_status["timing"],
|
||||
}
|
||||
|
||||
def get_next_frame(self) -> bytes:
|
||||
receiver_frame = self._receiver.get_latest_frame()
|
||||
if receiver_frame is not None:
|
||||
return receiver_frame
|
||||
raise RuntimeError("no live OmniSocket JPEG frame is currently available")
|
||||
|
||||
def iter_mjpeg(self, fps: float = 6.0) -> Iterator[bytes]:
|
||||
frame_interval = 1.0 / max(1.0, min(fps, 30.0))
|
||||
while True:
|
||||
frame = self.get_next_frame()
|
||||
header = (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
||||
)
|
||||
yield header + frame + b"\r\n"
|
||||
time.sleep(frame_interval)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
transport:
|
||||
server_addr: ""
|
||||
relay_via: 106.55.173.235:10909
|
||||
relay_via: "106.55.173.235:10909"
|
||||
bind_ip: ""
|
||||
bind_device: ""
|
||||
|
||||
@@ -8,6 +8,21 @@ video_receiver:
|
||||
peer_id: "peer-a-video"
|
||||
buffer_bytes: 1048576
|
||||
|
||||
control_sender:
|
||||
peer_id: "peer-a-ctrl"
|
||||
target_peer: "peer-b-ctrl"
|
||||
|
||||
control_ingress:
|
||||
native_udp_bind: "127.0.0.1:10921"
|
||||
source_lease_ms: 300
|
||||
send_rate_hz: 20.0
|
||||
zero_burst_packets: 3
|
||||
|
||||
telemetry_receiver:
|
||||
peer_id: "peer-a-telemetry"
|
||||
interval_ms: 500
|
||||
stale_after_ms: 1500
|
||||
|
||||
video_sender:
|
||||
peer_id: "peer-b-video"
|
||||
target_peer: "peer-a-video"
|
||||
|
||||
@@ -63,24 +63,26 @@ const navItems = [
|
||||
.app-shell {
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 22px 0 40px;
|
||||
padding: 0 0 40px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 24px;
|
||||
background: rgba(8, 14, 26, 0.82);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 0 0 24px 24px;
|
||||
background: linear-gradient(180deg, #0a1324 0%, #08101d 100%);
|
||||
border: 1px solid rgba(133, 147, 169, 0.22);
|
||||
border-top: none;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
:global(.panel) {
|
||||
@@ -164,7 +166,6 @@ const navItems = [
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.topbar {
|
||||
position: static;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
527
frontend/src/components/ControlFeedback.vue
Normal file
527
frontend/src/components/ControlFeedback.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useControlInterface } from '@/composables/useControlInterface'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const {
|
||||
activeSource,
|
||||
activeSourceLabel,
|
||||
commandLabel,
|
||||
controlLimits,
|
||||
controlInputMode,
|
||||
controlInputModeLabel,
|
||||
controlTuning,
|
||||
commandValues,
|
||||
gamepadActive,
|
||||
gamepadButtons,
|
||||
gamepadConnected,
|
||||
gamepadIndex,
|
||||
gamepadLeftStick,
|
||||
gamepadMapping,
|
||||
gamepadName,
|
||||
gamepadRightStick,
|
||||
keyboardActive,
|
||||
keyboardKeys,
|
||||
keyboardTurbo,
|
||||
lastServerMessage,
|
||||
pressedKeysLabel,
|
||||
socketLabel,
|
||||
socketState,
|
||||
} = useControlInterface()
|
||||
|
||||
const keyClusters = computed(() => {
|
||||
const lookup = new Map(keyboardKeys.value.map((entry) => [entry.code, entry]))
|
||||
return [
|
||||
lookup.get('KeyW'),
|
||||
lookup.get('KeyA'),
|
||||
lookup.get('KeyS'),
|
||||
lookup.get('KeyD'),
|
||||
lookup.get('KeyQ'),
|
||||
lookup.get('KeyE'),
|
||||
lookup.get('ShiftLeft'),
|
||||
lookup.get('Space'),
|
||||
].filter((entry): entry is NonNullable<typeof entry> => entry != null)
|
||||
})
|
||||
|
||||
const commandBars = computed(() => [
|
||||
{
|
||||
label: 'Forward',
|
||||
value: commandValues.value.lx,
|
||||
max: controlLimits.value.forward,
|
||||
},
|
||||
{
|
||||
label: 'Strafe',
|
||||
value: commandValues.value.ly,
|
||||
max: controlLimits.value.strafe,
|
||||
},
|
||||
{
|
||||
label: 'Turn',
|
||||
value: commandValues.value.az,
|
||||
max: controlLimits.value.turn,
|
||||
},
|
||||
])
|
||||
|
||||
function meterPosition(value: number, max: number) {
|
||||
const normalized = Math.max(-1, Math.min(1, value / max))
|
||||
return `${50 + normalized * 45}%`
|
||||
}
|
||||
|
||||
function stickOffset(value: number) {
|
||||
return `${value * 22}px`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feedback-shell" :class="{ compact }">
|
||||
<div class="feedback-topline">
|
||||
<div class="headline-stack">
|
||||
<div class="source-chip" :class="activeSource">
|
||||
{{ activeSourceLabel }}
|
||||
</div>
|
||||
<div class="input-chip">
|
||||
{{ controlInputModeLabel }} mode
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-stack">
|
||||
<span class="socket-chip" :class="socketState">{{ socketLabel }}</span>
|
||||
<span class="server-text">{{ lastServerMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="command-strip">
|
||||
<div
|
||||
v-for="bar in commandBars"
|
||||
:key="bar.label"
|
||||
class="command-card"
|
||||
>
|
||||
<div class="command-head">
|
||||
<span>{{ bar.label }}</span>
|
||||
<strong>{{ bar.value.toFixed(2) }}</strong>
|
||||
</div>
|
||||
<div class="command-meter">
|
||||
<span class="center-line" />
|
||||
<span class="command-dot" :style="{ left: meterPosition(bar.value, bar.max) }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="summary">
|
||||
Tuning: fwd {{ controlTuning.forward.toFixed(2) }} m/s, strafe {{ controlTuning.strafe.toFixed(2) }} m/s,
|
||||
turn {{ controlTuning.turn.toFixed(2) }} rad/s, turbo x{{ controlTuning.turbo.toFixed(2) }}
|
||||
</p>
|
||||
|
||||
<div class="feedback-grid" :class="{ compact }">
|
||||
<section class="feedback-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Keyboard</p>
|
||||
<strong>{{ pressedKeysLabel }}</strong>
|
||||
</div>
|
||||
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
|
||||
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? 'Turbo' : 'Selected') : 'Standby' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="key-grid">
|
||||
<span
|
||||
v-for="key in keyClusters"
|
||||
:key="key.code"
|
||||
class="key-chip"
|
||||
:class="{ active: key.pressed, wide: key.code === 'Space' || key.code === 'ShiftLeft' }"
|
||||
>
|
||||
{{ key.label }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="feedback-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Gamepad</p>
|
||||
<strong>{{ gamepadConnected ? gamepadName : 'Waiting for controller' }}</strong>
|
||||
</div>
|
||||
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
|
||||
{{
|
||||
gamepadConnected
|
||||
? controlInputMode === 'gamepad'
|
||||
? 'Selected'
|
||||
: 'Standby'
|
||||
: 'Offline'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="subtle">
|
||||
{{
|
||||
gamepadConnected
|
||||
? `#${gamepadIndex} / mapping=${gamepadMapping || 'unknown'}`
|
||||
: 'Left stick drives, right stick turns, RB boosts, A stops.'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="sticks">
|
||||
<div class="stick-card">
|
||||
<span>Left stick</span>
|
||||
<div class="stick-pad">
|
||||
<span class="crosshair crosshair-x" />
|
||||
<span class="crosshair crosshair-y" />
|
||||
<span
|
||||
class="stick-dot"
|
||||
:style="{
|
||||
transform: `translate(${stickOffset(gamepadLeftStick.x)}, ${stickOffset(gamepadLeftStick.y)})`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stick-card">
|
||||
<span>Right stick</span>
|
||||
<div class="stick-pad">
|
||||
<span class="crosshair crosshair-x" />
|
||||
<span class="crosshair crosshair-y" />
|
||||
<span
|
||||
class="stick-dot accent"
|
||||
:style="{
|
||||
transform: `translate(${stickOffset(gamepadRightStick.x)}, ${stickOffset(gamepadRightStick.y)})`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-grid">
|
||||
<span
|
||||
v-for="button in gamepadButtons"
|
||||
:key="button.label"
|
||||
class="button-chip"
|
||||
:class="{ active: button.pressed }"
|
||||
>
|
||||
{{ button.label }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-if="!compact" class="summary accent">
|
||||
Outgoing command: {{ commandLabel }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-shell {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.feedback-shell.compact {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.headline-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-chip,
|
||||
.input-chip,
|
||||
.socket-chip,
|
||||
.mode-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.source-chip {
|
||||
background: rgba(78, 224, 168, 0.16);
|
||||
color: #86f0c7;
|
||||
}
|
||||
|
||||
.source-chip.keyboard {
|
||||
background: rgba(91, 122, 255, 0.18);
|
||||
color: #d3dcff;
|
||||
}
|
||||
|
||||
.source-chip.gamepad {
|
||||
background: rgba(255, 176, 87, 0.18);
|
||||
color: #ffd8a6;
|
||||
}
|
||||
|
||||
.source-chip.idle {
|
||||
background: rgba(133, 147, 169, 0.16);
|
||||
color: #cad3e8;
|
||||
}
|
||||
|
||||
.input-chip {
|
||||
background: rgba(123, 196, 255, 0.14);
|
||||
color: #dff1ff;
|
||||
}
|
||||
|
||||
.socket-chip {
|
||||
background: rgba(40, 199, 111, 0.16);
|
||||
color: #7ef0b5;
|
||||
}
|
||||
|
||||
.socket-chip.connecting,
|
||||
.socket-chip.closed {
|
||||
background: rgba(255, 176, 87, 0.18);
|
||||
color: #ffd29b;
|
||||
}
|
||||
|
||||
.server-text {
|
||||
max-width: 320px;
|
||||
color: #aeb9d2;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.command-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command-card,
|
||||
.feedback-card {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 14, 26, 0.86);
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
}
|
||||
|
||||
.command-head,
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-head span,
|
||||
.label {
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.command-head strong,
|
||||
.card-head strong {
|
||||
color: #f6f8fc;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.command-meter {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
margin-top: 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(255, 99, 99, 0.12), rgba(255, 255, 255, 0.05), rgba(78, 224, 168, 0.14));
|
||||
border: 1px solid rgba(133, 147, 169, 0.16);
|
||||
}
|
||||
|
||||
.center-line {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: rgba(222, 232, 255, 0.28);
|
||||
}
|
||||
|
||||
.command-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #fdfefe, #63e6a9 62%, #2d8e68 100%);
|
||||
box-shadow: 0 0 16px rgba(99, 230, 169, 0.38);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.feedback-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-grid.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.subtle,
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: #8d99b3;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary.accent {
|
||||
color: #aeb9d2;
|
||||
}
|
||||
|
||||
.mode-chip {
|
||||
background: rgba(133, 147, 169, 0.14);
|
||||
color: #cad3e8;
|
||||
}
|
||||
|
||||
.mode-chip.hot {
|
||||
background: rgba(255, 176, 87, 0.18);
|
||||
color: #ffd29b;
|
||||
}
|
||||
|
||||
.key-grid,
|
||||
.button-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.key-chip,
|
||||
.button-chip {
|
||||
min-width: 44px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 20, 37, 0.9);
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
color: #dfe7fb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.key-chip.wide {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.key-chip.active,
|
||||
.button-chip.active {
|
||||
background: linear-gradient(135deg, rgba(91, 122, 255, 0.28), rgba(77, 212, 172, 0.28));
|
||||
border-color: rgba(123, 196, 255, 0.6);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(91, 122, 255, 0.22);
|
||||
}
|
||||
|
||||
.sticks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stick-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stick-card span {
|
||||
color: #aeb9d2;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stick-pad {
|
||||
position: relative;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
background: radial-gradient(circle at center, rgba(91, 122, 255, 0.12), rgba(4, 8, 15, 0.95));
|
||||
}
|
||||
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
background: rgba(222, 232, 255, 0.18);
|
||||
}
|
||||
|
||||
.crosshair-x {
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.crosshair-y {
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.stick-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #f8fdff, #63e6a9 58%, #2a7e5f 100%);
|
||||
box-shadow: 0 0 18px rgba(99, 230, 169, 0.35);
|
||||
}
|
||||
|
||||
.stick-dot.accent {
|
||||
background: radial-gradient(circle at 30% 30%, #fffaf4, #ffb057 58%, #b06d21 100%);
|
||||
box-shadow: 0 0 18px rgba(255, 176, 87, 0.34);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.command-strip,
|
||||
.feedback-grid,
|
||||
.sticks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.feedback-topline,
|
||||
.command-head,
|
||||
.card-head {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.server-text {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
323
frontend/src/components/ControlPanel.vue
Normal file
323
frontend/src/components/ControlPanel.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ControlFeedback from '@/components/ControlFeedback.vue'
|
||||
import { useControlInterface } from '@/composables/useControlInterface'
|
||||
|
||||
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
|
||||
useControlInterface()
|
||||
|
||||
const inputModes = [
|
||||
{ id: 'keyboard', label: 'Keyboard', detail: 'Use W/S, A/D, Q/E, Shift, and Space.' },
|
||||
{ id: 'gamepad', label: 'Gamepad', detail: 'Use the browser-detected controller only.' },
|
||||
] as const
|
||||
|
||||
const forwardSpeed = computed({
|
||||
get: () => controlTuning.value.forward,
|
||||
set: (value: number) => setControlTuning({ forward: value }),
|
||||
})
|
||||
|
||||
const strafeSpeed = computed({
|
||||
get: () => controlTuning.value.strafe,
|
||||
set: (value: number) => setControlTuning({ strafe: value }),
|
||||
})
|
||||
|
||||
const turnSpeed = computed({
|
||||
get: () => controlTuning.value.turn,
|
||||
set: (value: number) => setControlTuning({ turn: value }),
|
||||
})
|
||||
|
||||
const turboMultiplier = computed({
|
||||
get: () => controlTuning.value.turbo,
|
||||
set: (value: number) => setControlTuning({ turbo: value }),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel control-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Control</p>
|
||||
<h2>Control Feedback</h2>
|
||||
</div>
|
||||
<button type="button" class="reset-button" @click="resetControlTuning">
|
||||
Reset Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="mode-panel">
|
||||
<div class="mode-panel-head">
|
||||
<div>
|
||||
<p class="mode-eyebrow">Input Mode</p>
|
||||
<p class="mode-copy">Only one local input mode can control the page at a time.</p>
|
||||
</div>
|
||||
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="mode-toggle" role="radiogroup" aria-label="Control input mode">
|
||||
<button
|
||||
v-for="mode in inputModes"
|
||||
:key="mode.id"
|
||||
type="button"
|
||||
class="mode-button"
|
||||
:class="{ active: controlInputMode === mode.id }"
|
||||
:aria-pressed="controlInputMode === mode.id"
|
||||
@click="setControlInputMode(mode.id)"
|
||||
>
|
||||
<strong>{{ mode.label }}</strong>
|
||||
<span>{{ mode.detail }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tuning-grid">
|
||||
<label class="tuning-field">
|
||||
<span>Forward</span>
|
||||
<input v-model.number="forwardSpeed" type="number" min="0.05" max="3" step="0.05" />
|
||||
<small>m/s</small>
|
||||
</label>
|
||||
|
||||
<label class="tuning-field">
|
||||
<span>Strafe</span>
|
||||
<input v-model.number="strafeSpeed" type="number" min="0.05" max="3" step="0.05" />
|
||||
<small>m/s</small>
|
||||
</label>
|
||||
|
||||
<label class="tuning-field">
|
||||
<span>Turn</span>
|
||||
<input v-model.number="turnSpeed" type="number" min="0.05" max="3" step="0.05" />
|
||||
<small>rad/s</small>
|
||||
</label>
|
||||
|
||||
<label class="tuning-field">
|
||||
<span>Turbo</span>
|
||||
<input v-model.number="turboMultiplier" type="number" min="1" max="3" step="0.1" />
|
||||
<small>x</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ControlFeedback />
|
||||
|
||||
<p class="hint">
|
||||
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> strafe, <code>Q/E</code> turn,
|
||||
<code>Shift</code> turbo, <code>Space</code> stop.
|
||||
</p>
|
||||
<p class="hint subtle">
|
||||
Speed tuning is shared by both local input modes and is saved in this browser.
|
||||
</p>
|
||||
<p class="hint subtle">
|
||||
Browser gamepad mode uses the left stick to drive, the right stick to turn,
|
||||
<code>RB</code> to boost, and <code>A</code> to send stop.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.control-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||
background: rgba(10, 20, 37, 0.88);
|
||||
color: #dfe7fb;
|
||||
border-radius: 999px;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
border-color: rgba(123, 196, 255, 0.48);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #ffb057;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.mode-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 14, 26, 0.86);
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
}
|
||||
|
||||
.mode-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mode-eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #7bc4ff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mode-copy {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mode-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(123, 196, 255, 0.14);
|
||||
color: #dff1ff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mode-button {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-height: 84px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
background: rgba(10, 20, 37, 0.9);
|
||||
color: #dfe7fb;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.mode-button strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mode-button span {
|
||||
color: #96a5c3;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mode-button:hover {
|
||||
border-color: rgba(123, 196, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mode-button.active {
|
||||
border-color: rgba(123, 196, 255, 0.6);
|
||||
background: linear-gradient(135deg, rgba(91, 122, 255, 0.24), rgba(77, 212, 172, 0.2));
|
||||
box-shadow: 0 10px 28px rgba(91, 122, 255, 0.18);
|
||||
}
|
||||
|
||||
.mode-button.active span {
|
||||
color: #d5e7ff;
|
||||
}
|
||||
|
||||
.tuning-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tuning-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.86);
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
}
|
||||
|
||||
.tuning-field span,
|
||||
.tuning-field small {
|
||||
color: #aeb9d2;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.tuning-field input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.24);
|
||||
background: rgba(10, 20, 37, 0.96);
|
||||
color: #f6f8fc;
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tuning-field input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(123, 196, 255, 0.62);
|
||||
box-shadow: 0 0 0 3px rgba(91, 122, 255, 0.18);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hint.subtle {
|
||||
color: #96a5c3;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.panel-head {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mode-panel-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tuning-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mode-toggle,
|
||||
.tuning-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NetworkTelemetry } from '@/types'
|
||||
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
network: NetworkTelemetry | null
|
||||
}>()
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
if (!props.network?.updated_at) {
|
||||
return '暂无'
|
||||
const legCards = computed(() => [
|
||||
{
|
||||
key: 'a_to_d',
|
||||
label: 'A <-> D',
|
||||
data: props.network?.links?.a_to_d ?? null,
|
||||
},
|
||||
{
|
||||
key: 'd_to_b',
|
||||
label: 'D <-> B',
|
||||
data: props.network?.links?.d_to_b ?? null,
|
||||
},
|
||||
])
|
||||
|
||||
const activeSource = computed(() => props.network?.active_control_source ?? 'none')
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return 'unavailable'
|
||||
}
|
||||
return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatScalar(value?: number | string | null, suffix = '') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--'
|
||||
}
|
||||
return `${value}${suffix}`
|
||||
}
|
||||
|
||||
function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: LinkSessionTelemetry | null }> {
|
||||
return [
|
||||
{ name: 'control', data: link?.sessions?.control ?? null },
|
||||
{ name: 'video', data: link?.sessions?.video ?? null },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,41 +49,139 @@ const updatedAt = computed(() => {
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h2>链路状态</h2>
|
||||
<h2>Dual-Leg Telemetry</h2>
|
||||
</div>
|
||||
<span class="badge">{{ network?.peer_status ?? 'loading' }}</span>
|
||||
<span class="badge" :class="{ stale: network?.telemetry_receiver?.hub_stale }">
|
||||
{{ network?.peer_status ?? 'loading' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>延迟</span>
|
||||
<strong>{{ network?.latency_ms ?? '--' }} ms</strong>
|
||||
<span>Latency</span>
|
||||
<strong>{{ formatScalar(network?.latency_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>抖动</span>
|
||||
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
|
||||
<span>Jitter</span>
|
||||
<strong>{{ formatScalar(network?.jitter_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Retrans</span>
|
||||
<strong>{{ network?.retrans_pct ?? network?.packet_loss_pct ?? '--' }} %</strong>
|
||||
<span>Active Control</span>
|
||||
<strong>{{ activeSource }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>信号强度</span>
|
||||
<strong>{{ network?.signal_dbm ?? '--' }} dBm</strong>
|
||||
<span>Lease</span>
|
||||
<strong>{{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>发送速率</span>
|
||||
<strong>{{ network?.tx_kbps ?? '--' }} kbps</strong>
|
||||
<span>TX Rate</span>
|
||||
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>接收速率</span>
|
||||
<strong>{{ network?.rx_kbps ?? '--' }} kbps</strong>
|
||||
<span>RX Rate</span>
|
||||
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary telemetry-strip">
|
||||
<p><strong>Transport:</strong> {{ network?.transport ?? 'n/a' }} / {{ network?.source_mode ?? 'n/a' }}</p>
|
||||
<p><strong>Telemetry Peer:</strong> {{ network?.telemetry_receiver?.peer_id ?? 'n/a' }}</p>
|
||||
<p><strong>Telemetry Registered:</strong> {{ network?.telemetry_receiver?.registered ? 'yes' : 'no' }}</p>
|
||||
<p><strong>Hub Freshness:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
|
||||
<p><strong>Hub State:</strong> {{ network?.telemetry_receiver?.hub_stale ? 'stale' : 'fresh' }}</p>
|
||||
<p><strong>Telemetry Reconnects:</strong> {{ network?.telemetry_receiver?.reconnect_count ?? 0 }}</p>
|
||||
<p v-if="network?.telemetry_receiver?.last_error"><strong>Hub Error:</strong> {{ network?.telemetry_receiver?.last_error }}</p>
|
||||
<p v-if="network?.telemetry_receiver?.last_server_error"><strong>Telemetry Session Error:</strong> {{ network?.telemetry_receiver?.last_server_error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="leg-grid">
|
||||
<article v-for="leg in legCards" :key="leg.key" class="leg-card" :class="{ stale: leg.data?.stale }">
|
||||
<div class="leg-head">
|
||||
<div>
|
||||
<p class="leg-label">{{ leg.label }}</p>
|
||||
<h3>{{ leg.data?.source ?? 'waiting' }}</h3>
|
||||
</div>
|
||||
<div class="leg-meta">
|
||||
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
|
||||
{{ leg.data?.stale ? 'stale' : 'fresh' }}
|
||||
</span>
|
||||
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aggregate-grid">
|
||||
<div>
|
||||
<span>Online</span>
|
||||
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Max Pressure</span>
|
||||
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Queued</span>
|
||||
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>In Flight Buffer</span>
|
||||
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Retrans Delta</span>
|
||||
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Repair Rate</span>
|
||||
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-grid">
|
||||
<section v-for="session in legSessions(leg.data)" :key="session.name" class="session-card">
|
||||
<div class="session-head">
|
||||
<div>
|
||||
<p class="session-label">{{ session.name }}</p>
|
||||
<h4>{{ session.data?.peer_id ?? 'unassigned' }}</h4>
|
||||
</div>
|
||||
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
|
||||
{{ session.data?.connected ? 'online' : 'idle' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<p><strong>Updated:</strong> {{ formatTime(session.data?.updated_at) }}</p>
|
||||
<p><strong>SRTT:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
|
||||
<p><strong>RTTVAR:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
|
||||
<p><strong>RTO:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
|
||||
<p><strong>SND WND:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
|
||||
<p><strong>RMT WND:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
|
||||
<p><strong>Inflight:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
|
||||
<p><strong>Window Limit:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
|
||||
<p><strong>Pressure:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
|
||||
<p><strong>SND Queue:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ session.data?.trend?.snd_queue_trend ?? 'stable' }}</p>
|
||||
<p><strong>SND Buffer:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ session.data?.trend?.snd_buffer_trend ?? 'stable' }}</p>
|
||||
<p><strong>Queue Delta:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
|
||||
<p><strong>Buffer Delta:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
|
||||
<p><strong>Retrans:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
|
||||
<p><strong>Fast Retrans:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
|
||||
<p><strong>Lost:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
|
||||
<p><strong>Repeat:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
|
||||
<p><strong>Repair Rate:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</p>
|
||||
<p v-if="session.data?.app"><strong>App Bytes:</strong> tx={{ session.data.app.send_bytes ?? 0 }} / rx={{ session.data.app.recv_bytes ?? 0 }}</p>
|
||||
<p v-if="session.data?.app"><strong>Registered:</strong> {{ session.data.app.registered ? 'yes' : 'no' }}</p>
|
||||
<p v-if="session.data?.app?.last_server_error"><strong>Server Error:</strong> {{ session.data.app.last_server_error }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<p><strong>来源:</strong>{{ network?.transport ?? '暂无' }} / {{ network?.source_mode ?? '暂无' }}</p>
|
||||
<p><strong>刷新:</strong>{{ updatedAt }}</p>
|
||||
<p><strong>Combined:</strong> sessions={{ network?.combined?.connected_sessions ?? 0 }} send={{ network?.combined?.send_bytes ?? 0 }}B recv={{ network?.combined?.recv_bytes ?? 0 }}B</p>
|
||||
<p><strong>Native UDP:</strong> {{ network?.ingress?.native_udp?.bind_addr ?? 'n/a' }} packets={{ network?.ingress?.native_udp?.packets_received ?? 0 }} invalid={{ network?.ingress?.native_udp?.invalid_packets ?? 0 }}</p>
|
||||
<p><strong>Control Sender:</strong> {{ network?.control?.sender?.peer_id ?? 'n/a' }} -> {{ network?.control?.sender?.target_peer ?? 'n/a' }} sends={{ network?.control?.sender?.send_count ?? 0 }} registered={{ network?.control?.sender?.registered ? 'yes' : 'no' }}</p>
|
||||
<p><strong>Control Reconnects:</strong> {{ network?.control?.sender?.reconnect_count ?? 0 }}</p>
|
||||
<p v-if="network?.control?.sender?.last_server_error"><strong>Control Session Error:</strong> {{ network?.control?.sender?.last_server_error }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -62,73 +189,134 @@ const updatedAt = computed(() => {
|
||||
<style scoped>
|
||||
.network-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
.panel-head,
|
||||
.leg-head,
|
||||
.session-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
.eyebrow,
|
||||
.leg-label,
|
||||
.session-label {
|
||||
margin: 0 0 4px;
|
||||
color: #4dd4ac;
|
||||
color: #5bd3b5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mini-badge {
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(40, 199, 111, 0.16);
|
||||
color: #63e6a9;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats {
|
||||
.mini-badge {
|
||||
padding: 6px 10px;
|
||||
background: rgba(91, 211, 181, 0.12);
|
||||
color: #8ff2db;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge.stale,
|
||||
.mini-badge.stale {
|
||||
background: rgba(255, 165, 0, 0.16);
|
||||
color: #ffd08a;
|
||||
}
|
||||
|
||||
.mini-badge.active {
|
||||
background: rgba(64, 187, 255, 0.16);
|
||||
color: #98dcff;
|
||||
}
|
||||
|
||||
.stats,
|
||||
.leg-grid,
|
||||
.session-grid,
|
||||
.aggregate-grid,
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
.stats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
.leg-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.session-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.aggregate-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.summary,
|
||||
.leg-card,
|
||||
.session-card {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 14, 26, 0.8);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.stat-card span,
|
||||
.aggregate-grid span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
.stat-card strong,
|
||||
.aggregate-grid strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.summary p {
|
||||
.summary p,
|
||||
.kv-grid p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -136,14 +324,57 @@ h2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats {
|
||||
.telemetry-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.leg-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.leg-card.stale {
|
||||
border-color: rgba(255, 165, 0, 0.3);
|
||||
}
|
||||
|
||||
.leg-meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-time {
|
||||
color: #9aa6c2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
background: rgba(11, 19, 35, 0.86);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats,
|
||||
.aggregate-grid,
|
||||
.telemetry-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.leg-grid,
|
||||
.session-grid,
|
||||
.kv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats {
|
||||
@media (max-width: 720px) {
|
||||
.stats,
|
||||
.aggregate-grid,
|
||||
.telemetry-strip,
|
||||
.kv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { buildVideoFrameUrl } from '@/lib/api'
|
||||
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
|
||||
import type { VideoStatus } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
video: VideoStatus | null
|
||||
}>()
|
||||
|
||||
const STATUS_REFRESH_MS = 300
|
||||
|
||||
const liveVideo = ref<VideoStatus | null>(props.video)
|
||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||
const currentFps = computed(() => props.video?.fps ?? 30)
|
||||
const canRequestFrames = computed(() => props.video?.available === true)
|
||||
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
||||
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
|
||||
const canRequestFrames = computed(() => displayVideo.value?.available === true)
|
||||
const modeLabel = computed(() => {
|
||||
if (!props.video) {
|
||||
if (!displayVideo.value) {
|
||||
return '正在获取视频状态'
|
||||
}
|
||||
if (props.video.source_mode === 'omnisocket-jpeg-live') {
|
||||
return `${props.video.fps} FPS 实时接收`
|
||||
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
|
||||
return `${displayVideo.value.fps} FPS 实时接收`
|
||||
}
|
||||
if (props.video.source_mode === 'omnisocket-waiting') {
|
||||
if (displayVideo.value.source_mode === 'omnisocket-waiting') {
|
||||
return '未实时获取真实值'
|
||||
}
|
||||
return `${props.video.fps} FPS`
|
||||
return `${displayVideo.value.fps} FPS`
|
||||
})
|
||||
const placeholderText = computed(() => {
|
||||
if (!props.video) {
|
||||
if (!displayVideo.value) {
|
||||
return '正在获取视频状态...'
|
||||
}
|
||||
return '未实时获取真实值'
|
||||
})
|
||||
const latencyLabels = computed(() => {
|
||||
const sampleWindowSize = displayVideo.value?.timing?.sample_window_size ?? 10
|
||||
const samples = displayVideo.value?.timing?.delta_samples_ms ?? []
|
||||
return Array.from({ length: sampleWindowSize }, (_, index) => samples[index] ?? null)
|
||||
})
|
||||
const timingHeadline = computed(() => {
|
||||
const latest = displayVideo.value?.timing?.latest_delta_ms
|
||||
if (latest == null) {
|
||||
return '等待帧尾时间'
|
||||
}
|
||||
return `最新 ${latest.toFixed(1)} ms`
|
||||
})
|
||||
const timingHint = computed(() => {
|
||||
const timing = displayVideo.value?.timing
|
||||
if (!timing?.available) {
|
||||
return '当前还没有从 JPEG 结尾后的尾字节里解析到时间戳,标签会在收到有效帧尾时间后自动填充。'
|
||||
}
|
||||
|
||||
const unitText = timing.timestamp_unit ? `,单位按 ${timing.timestamp_unit}` : ''
|
||||
const endiannessText = timing.timestamp_endianness
|
||||
? `,字节序按 ${timing.timestamp_endianness}`
|
||||
: ''
|
||||
return `最近 ${timing.sample_window_size} 个差值样本,面板按 ${STATUS_REFRESH_MS} ms 刷新一组${unitText}${endiannessText}。`
|
||||
})
|
||||
|
||||
let frameTimer: number | null = null
|
||||
let statusTimer: number | null = null
|
||||
let frameKey = 0
|
||||
let statusRequestPending = false
|
||||
|
||||
async function refreshStatus() {
|
||||
if (statusRequestPending) {
|
||||
return
|
||||
}
|
||||
|
||||
statusRequestPending = true
|
||||
try {
|
||||
liveVideo.value = await fetchVideoStatus()
|
||||
} catch {
|
||||
// 保持当前已显示状态,避免短暂请求失败把面板内容清空。
|
||||
} finally {
|
||||
statusRequestPending = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshFrame() {
|
||||
if (!canRequestFrames.value) {
|
||||
@@ -58,7 +102,20 @@ function startFrameLoop() {
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function startStatusLoop() {
|
||||
if (statusTimer != null) {
|
||||
window.clearInterval(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
|
||||
void refreshStatus()
|
||||
statusTimer = window.setInterval(() => {
|
||||
void refreshStatus()
|
||||
}, STATUS_REFRESH_MS)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startStatusLoop()
|
||||
startFrameLoop()
|
||||
})
|
||||
|
||||
@@ -66,8 +123,19 @@ onUnmounted(() => {
|
||||
if (frameTimer != null) {
|
||||
window.clearInterval(frameTimer)
|
||||
}
|
||||
if (statusTimer != null) {
|
||||
window.clearInterval(statusTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.video,
|
||||
(nextVideo) => {
|
||||
liveVideo.value = nextVideo
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([currentFps, canRequestFrames], () => {
|
||||
startFrameLoop()
|
||||
})
|
||||
@@ -80,8 +148,8 @@ watch([currentFps, canRequestFrames], () => {
|
||||
<p class="eyebrow">Video</p>
|
||||
<h2>JPEG 视频流</h2>
|
||||
</div>
|
||||
<span class="badge" :class="{ bad: !video?.available }">
|
||||
{{ video?.source_mode ?? 'loading' }}
|
||||
<span class="badge" :class="{ bad: !displayVideo?.available }">
|
||||
{{ displayVideo?.source_mode ?? 'loading' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +168,7 @@ watch([currentFps, canRequestFrames], () => {
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>帧源</span>
|
||||
<strong>{{ video?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||
<strong>{{ displayVideo?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>当前模式</span>
|
||||
@@ -108,13 +176,33 @@ watch([currentFps, canRequestFrames], () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timing-panel">
|
||||
<div class="timing-head">
|
||||
<span>帧尾时间差</span>
|
||||
<strong>{{ timingHeadline }}</strong>
|
||||
</div>
|
||||
<div class="timing-grid">
|
||||
<span
|
||||
v-for="(sample, index) in latencyLabels"
|
||||
:key="index"
|
||||
class="timing-label"
|
||||
:class="{ empty: sample == null }"
|
||||
>
|
||||
{{ sample == null ? '--' : `${sample.toFixed(1)} ms` }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="hint subtle">
|
||||
{{ timingHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
这里只有在后端已经收到 OmniSocket 的真实 JPEG 帧时,才会开始逐帧请求并显示画面。
|
||||
如果当前没有真实帧,页面会保持占位提示,不再回退测试视频流。
|
||||
</p>
|
||||
|
||||
<p class="hint subtle">
|
||||
当前帧源状态:{{ video?.source_detail ?? '暂无' }}
|
||||
当前帧源状态:{{ displayVideo?.source_detail ?? '暂无' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -161,6 +249,7 @@ h2 {
|
||||
}
|
||||
|
||||
.video-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||
@@ -213,6 +302,57 @@ h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.timing-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 14, 26, 0.88);
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
}
|
||||
|
||||
.timing-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #cfd7e6;
|
||||
}
|
||||
|
||||
.timing-head span {
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timing-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.timing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(91, 122, 255, 0.12);
|
||||
border: 1px solid rgba(91, 122, 255, 0.28);
|
||||
color: #dce4ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timing-label.empty {
|
||||
background: rgba(133, 147, 169, 0.08);
|
||||
border-color: rgba(133, 147, 169, 0.18);
|
||||
color: #7e8aa5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #8d99b3;
|
||||
@@ -227,5 +367,9 @@ h2 {
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timing-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
710
frontend/src/composables/useControlInterface.ts
Normal file
710
frontend/src/composables/useControlInterface.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { buildControlWebSocketUrl } from '@/lib/api'
|
||||
|
||||
type SocketState = 'connecting' | 'open' | 'closed'
|
||||
type ControlInputMode = 'keyboard' | 'gamepad'
|
||||
type ControlSource = 'keyboard' | 'gamepad' | 'idle'
|
||||
type CommandTuple = [number, number, number, number, number, number]
|
||||
|
||||
type KeyFeedback = {
|
||||
code: string
|
||||
label: string
|
||||
pressed: boolean
|
||||
}
|
||||
|
||||
type ButtonFeedback = {
|
||||
label: string
|
||||
pressed: boolean
|
||||
}
|
||||
|
||||
type ControlTuning = {
|
||||
forward: number
|
||||
strafe: number
|
||||
turn: number
|
||||
turbo: number
|
||||
}
|
||||
|
||||
const TRACKED_KEYS = ['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space']
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
KeyW: 'W',
|
||||
KeyS: 'S',
|
||||
KeyA: 'A',
|
||||
KeyD: 'D',
|
||||
KeyQ: 'Q',
|
||||
KeyE: 'E',
|
||||
ShiftLeft: 'Shift',
|
||||
ShiftRight: 'Shift',
|
||||
Space: 'Stop',
|
||||
}
|
||||
const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS']
|
||||
|
||||
const ZERO_COMMAND: CommandTuple = [0, 0, 0, 0, 0, 0]
|
||||
const GAMEPAD_DEADZONE = 0.14
|
||||
const COMMAND_SEND_INTERVAL_MS = 50
|
||||
const DEFAULT_CONTROL_TUNING: ControlTuning = {
|
||||
forward: 0.8,
|
||||
strafe: 0.15,
|
||||
turn: 0.4,
|
||||
turbo: 1.5,
|
||||
}
|
||||
const CONTROL_INPUT_MODE_STORAGE_KEY = 'robot-command-center.control-input-mode'
|
||||
const CONTROL_TUNING_STORAGE_KEY = 'robot-command-center.control-tuning'
|
||||
const MIN_AXIS_SPEED = 0.05
|
||||
const MAX_AXIS_SPEED = 3
|
||||
const MIN_TURBO_MULTIPLIER = 1
|
||||
const MAX_TURBO_MULTIPLIER = 3
|
||||
|
||||
const pressedKeys = ref<Set<string>>(new Set())
|
||||
const socketState = ref<SocketState>('connecting')
|
||||
const lastServerMessage = ref('waiting')
|
||||
const gamepadSupported = ref(false)
|
||||
const gamepadConnected = ref(false)
|
||||
const gamepadName = ref('No gamepad detected')
|
||||
const gamepadIndex = ref<number | null>(null)
|
||||
const gamepadMapping = ref('')
|
||||
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
|
||||
const gamepadButtonPressed = ref<boolean[]>(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false))
|
||||
const activeSource = ref<ControlSource>('idle')
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function sanitizeAxisSpeed(value: unknown, fallback: number) {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return fallback
|
||||
}
|
||||
return roundValue(clampValue(numericValue, MIN_AXIS_SPEED, MAX_AXIS_SPEED))
|
||||
}
|
||||
|
||||
function sanitizeTurboMultiplier(value: unknown, fallback: number) {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return fallback
|
||||
}
|
||||
return roundValue(clampValue(numericValue, MIN_TURBO_MULTIPLIER, MAX_TURBO_MULTIPLIER))
|
||||
}
|
||||
|
||||
function normalizeControlTuning(raw?: Partial<ControlTuning>): ControlTuning {
|
||||
return {
|
||||
forward: sanitizeAxisSpeed(raw?.forward, DEFAULT_CONTROL_TUNING.forward),
|
||||
strafe: sanitizeAxisSpeed(raw?.strafe, DEFAULT_CONTROL_TUNING.strafe),
|
||||
turn: sanitizeAxisSpeed(raw?.turn, DEFAULT_CONTROL_TUNING.turn),
|
||||
turbo: sanitizeTurboMultiplier(raw?.turbo, DEFAULT_CONTROL_TUNING.turbo),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeControlInputMode(raw: unknown): ControlInputMode {
|
||||
return raw === 'gamepad' ? 'gamepad' : 'keyboard'
|
||||
}
|
||||
|
||||
function loadPersistedControlTuning() {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CONTROL_TUNING
|
||||
}
|
||||
|
||||
let raw: string | null = null
|
||||
|
||||
try {
|
||||
raw = window.localStorage.getItem(CONTROL_TUNING_STORAGE_KEY)
|
||||
} catch {
|
||||
return DEFAULT_CONTROL_TUNING
|
||||
}
|
||||
|
||||
if (raw == null) {
|
||||
return DEFAULT_CONTROL_TUNING
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeControlTuning(JSON.parse(raw) as Partial<ControlTuning>)
|
||||
} catch {
|
||||
return DEFAULT_CONTROL_TUNING
|
||||
}
|
||||
}
|
||||
|
||||
function loadPersistedControlInputMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return normalizeControlInputMode(null)
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeControlInputMode(window.localStorage.getItem(CONTROL_INPUT_MODE_STORAGE_KEY))
|
||||
} catch {
|
||||
return normalizeControlInputMode(null)
|
||||
}
|
||||
}
|
||||
|
||||
const controlInputMode = ref<ControlInputMode>(loadPersistedControlInputMode())
|
||||
const initialControlTuning = loadPersistedControlTuning()
|
||||
const forwardSpeed = ref(initialControlTuning.forward)
|
||||
const strafeSpeed = ref(initialControlTuning.strafe)
|
||||
const turnSpeed = ref(initialControlTuning.turn)
|
||||
const turboMultiplier = ref(initialControlTuning.turbo)
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let sendTimer: number | null = null
|
||||
let reconnectTimer: number | null = null
|
||||
let gamepadTimer: number | null = null
|
||||
let manualClose = false
|
||||
let consumerCount = 0
|
||||
let lastGamepadSignature = ''
|
||||
let lastCommandSignature = ''
|
||||
|
||||
function normalizeAxis(raw: number) {
|
||||
if (Math.abs(raw) < GAMEPAD_DEADZONE) {
|
||||
return 0
|
||||
}
|
||||
const sign = raw >= 0 ? 1 : -1
|
||||
return sign * ((Math.abs(raw) - GAMEPAD_DEADZONE) / (1 - GAMEPAD_DEADZONE))
|
||||
}
|
||||
|
||||
function roundValue(value: number) {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
function persistControlTuning() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
CONTROL_TUNING_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
forward: forwardSpeed.value,
|
||||
strafe: strafeSpeed.value,
|
||||
turn: turnSpeed.value,
|
||||
turbo: turboMultiplier.value,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage failures so tuning still works for the current session.
|
||||
}
|
||||
}
|
||||
|
||||
function persistControlInputMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(CONTROL_INPUT_MODE_STORAGE_KEY, controlInputMode.value)
|
||||
} catch {
|
||||
// Ignore storage failures so mode switching still works for the current session.
|
||||
}
|
||||
}
|
||||
|
||||
function setControlInputMode(next: ControlInputMode) {
|
||||
const resolved = normalizeControlInputMode(next)
|
||||
const previous = controlInputMode.value
|
||||
|
||||
if (resolved === previous) {
|
||||
return
|
||||
}
|
||||
|
||||
controlInputMode.value = resolved
|
||||
persistControlInputMode()
|
||||
|
||||
if (previous === 'keyboard') {
|
||||
pressedKeys.value = new Set()
|
||||
}
|
||||
|
||||
refreshSendLoop(true)
|
||||
}
|
||||
|
||||
function setControlTuning(next: Partial<ControlTuning>) {
|
||||
const resolved = normalizeControlTuning({
|
||||
forward: next.forward ?? forwardSpeed.value,
|
||||
strafe: next.strafe ?? strafeSpeed.value,
|
||||
turn: next.turn ?? turnSpeed.value,
|
||||
turbo: next.turbo ?? turboMultiplier.value,
|
||||
})
|
||||
const changed =
|
||||
resolved.forward !== forwardSpeed.value ||
|
||||
resolved.strafe !== strafeSpeed.value ||
|
||||
resolved.turn !== turnSpeed.value ||
|
||||
resolved.turbo !== turboMultiplier.value
|
||||
|
||||
forwardSpeed.value = resolved.forward
|
||||
strafeSpeed.value = resolved.strafe
|
||||
turnSpeed.value = resolved.turn
|
||||
turboMultiplier.value = resolved.turbo
|
||||
persistControlTuning()
|
||||
|
||||
if (changed) {
|
||||
refreshSendLoop(true)
|
||||
}
|
||||
}
|
||||
|
||||
function resetControlTuning() {
|
||||
setControlTuning(DEFAULT_CONTROL_TUNING)
|
||||
}
|
||||
|
||||
function packCommand(values: CommandTuple) {
|
||||
const buffer = new ArrayBuffer(24)
|
||||
const view = new DataView(buffer)
|
||||
values.forEach((value, index) => view.setFloat32(index * 4, value, true))
|
||||
return buffer
|
||||
}
|
||||
|
||||
function isZeroCommand(values: CommandTuple) {
|
||||
return values.every((value) => Math.abs(value) < 0.0001)
|
||||
}
|
||||
|
||||
function commandSignature(values: CommandTuple, source: ControlSource) {
|
||||
return `${source}:${values.map((value) => value.toFixed(3)).join(',')}`
|
||||
}
|
||||
|
||||
function activeTurnAxis() {
|
||||
const axis2 = normalizeAxis(gamepadAxes.value[2] ?? 0)
|
||||
const axis3 = normalizeAxis(gamepadAxes.value[3] ?? 0)
|
||||
return Math.abs(axis2) >= Math.abs(axis3) ? axis2 : axis3
|
||||
}
|
||||
|
||||
function keyboardCommandValues(): CommandTuple {
|
||||
const keys = pressedKeys.value
|
||||
const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? turboMultiplier.value : 1
|
||||
|
||||
let lx = 0
|
||||
let ly = 0
|
||||
let az = 0
|
||||
|
||||
if (keys.has('KeyW')) lx += forwardSpeed.value
|
||||
if (keys.has('KeyS')) lx -= forwardSpeed.value
|
||||
if (keys.has('KeyA')) ly += strafeSpeed.value
|
||||
if (keys.has('KeyD')) ly -= strafeSpeed.value
|
||||
if (keys.has('KeyQ')) az += turnSpeed.value
|
||||
if (keys.has('KeyE')) az -= turnSpeed.value
|
||||
|
||||
if (keys.has('Space')) {
|
||||
return ZERO_COMMAND
|
||||
}
|
||||
|
||||
return [
|
||||
roundValue(lx * turbo),
|
||||
roundValue(ly * turbo),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
roundValue(az * turbo),
|
||||
]
|
||||
}
|
||||
|
||||
function gamepadCommandValues(): CommandTuple {
|
||||
if (!gamepadConnected.value) {
|
||||
return ZERO_COMMAND
|
||||
}
|
||||
|
||||
const buttons = gamepadButtonPressed.value
|
||||
const turbo = buttons[5] ? turboMultiplier.value : 1
|
||||
|
||||
if (buttons[0]) {
|
||||
return ZERO_COMMAND
|
||||
}
|
||||
|
||||
const lx = roundValue(-normalizeAxis(gamepadAxes.value[1] ?? 0) * forwardSpeed.value * turbo)
|
||||
const ly = roundValue(-normalizeAxis(gamepadAxes.value[0] ?? 0) * strafeSpeed.value * turbo)
|
||||
const az = roundValue(-activeTurnAxis() * turnSpeed.value * turbo)
|
||||
|
||||
return [lx, ly, 0, 0, 0, az]
|
||||
}
|
||||
|
||||
function keyboardActiveRaw() {
|
||||
return pressedKeys.value.size > 0
|
||||
}
|
||||
|
||||
function keyboardActive() {
|
||||
return controlInputMode.value === 'keyboard' && keyboardActiveRaw()
|
||||
}
|
||||
|
||||
function gamepadActiveRaw() {
|
||||
if (!gamepadConnected.value) {
|
||||
return false
|
||||
}
|
||||
return !isZeroCommand(gamepadCommandValues()) || gamepadButtonPressed.value.some(Boolean)
|
||||
}
|
||||
|
||||
function gamepadActiveInternal() {
|
||||
return controlInputMode.value === 'gamepad' && gamepadActiveRaw()
|
||||
}
|
||||
|
||||
function resolvedSource(): ControlSource {
|
||||
if (controlInputMode.value === 'keyboard' && keyboardActiveRaw()) {
|
||||
return 'keyboard'
|
||||
}
|
||||
if (controlInputMode.value === 'gamepad' && gamepadActiveRaw()) {
|
||||
return 'gamepad'
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function resolvedCommandValues(): CommandTuple {
|
||||
const source = resolvedSource()
|
||||
activeSource.value = source
|
||||
if (source === 'keyboard') {
|
||||
return keyboardCommandValues()
|
||||
}
|
||||
if (source === 'gamepad') {
|
||||
return gamepadCommandValues()
|
||||
}
|
||||
return ZERO_COMMAND
|
||||
}
|
||||
|
||||
function stopSendLoop() {
|
||||
if (sendTimer != null) {
|
||||
window.clearInterval(sendTimer)
|
||||
sendTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function sendCurrentCommand() {
|
||||
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
socket.send(packCommand(resolvedCommandValues()))
|
||||
}
|
||||
|
||||
function refreshSendLoop(force = false) {
|
||||
const source = resolvedSource()
|
||||
const values = resolvedCommandValues()
|
||||
const signature = commandSignature(values, source)
|
||||
|
||||
if (!force && signature === lastCommandSignature) {
|
||||
return
|
||||
}
|
||||
lastCommandSignature = signature
|
||||
|
||||
stopSendLoop()
|
||||
if (socket == null || socket.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
sendCurrentCommand()
|
||||
if (isZeroCommand(values)) {
|
||||
return
|
||||
}
|
||||
|
||||
sendTimer = window.setInterval(() => {
|
||||
sendCurrentCommand()
|
||||
}, COMMAND_SEND_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function clearKeyboardCommands() {
|
||||
pressedKeys.value = new Set()
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!TRACKED_KEYS.includes(event.code)) {
|
||||
return
|
||||
}
|
||||
if (controlInputMode.value !== 'keyboard') {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const tag = event.target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const next = new Set(pressedKeys.value)
|
||||
next.add(event.code)
|
||||
pressedKeys.value = next
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
function handleKeyup(event: KeyboardEvent) {
|
||||
if (!TRACKED_KEYS.includes(event.code)) {
|
||||
return
|
||||
}
|
||||
if (controlInputMode.value !== 'keyboard') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const next = new Set(pressedKeys.value)
|
||||
next.delete(event.code)
|
||||
pressedKeys.value = next
|
||||
refreshSendLoop()
|
||||
}
|
||||
|
||||
function resetGamepadState() {
|
||||
gamepadConnected.value = false
|
||||
gamepadName.value = 'No gamepad detected'
|
||||
gamepadIndex.value = null
|
||||
gamepadMapping.value = ''
|
||||
gamepadAxes.value = [0, 0, 0, 0]
|
||||
gamepadButtonPressed.value = Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false)
|
||||
}
|
||||
|
||||
function pollGamepadState() {
|
||||
gamepadSupported.value = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
|
||||
if (!gamepadSupported.value) {
|
||||
resetGamepadState()
|
||||
if (controlInputMode.value === 'gamepad') {
|
||||
refreshSendLoop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pad = Array.from(navigator.getGamepads()).find((entry): entry is Gamepad => entry != null)
|
||||
|
||||
if (pad == null) {
|
||||
if (gamepadConnected.value) {
|
||||
resetGamepadState()
|
||||
lastGamepadSignature = ''
|
||||
if (controlInputMode.value === 'gamepad') {
|
||||
refreshSendLoop()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const axes = Array.from({ length: 4 }, (_, index) => roundValue(normalizeAxis(pad.axes[index] ?? 0)))
|
||||
const buttons = GAMEPAD_BUTTON_LABELS.map((_, index) => Boolean(pad.buttons[index]?.pressed))
|
||||
const signature = `${pad.index}:${pad.id}:${pad.mapping}:${axes.join(',')}:${buttons.map((pressed) => (pressed ? '1' : '0')).join('')}`
|
||||
|
||||
if (signature === lastGamepadSignature) {
|
||||
return
|
||||
}
|
||||
|
||||
lastGamepadSignature = signature
|
||||
gamepadConnected.value = true
|
||||
gamepadName.value = pad.id || 'Unnamed gamepad'
|
||||
gamepadIndex.value = pad.index
|
||||
gamepadMapping.value = pad.mapping || 'unknown'
|
||||
gamepadAxes.value = axes
|
||||
gamepadButtonPressed.value = buttons
|
||||
if (controlInputMode.value === 'gamepad') {
|
||||
refreshSendLoop()
|
||||
}
|
||||
}
|
||||
|
||||
function connectSocket() {
|
||||
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
manualClose = false
|
||||
socketState.value = 'connecting'
|
||||
socket = new WebSocket(buildControlWebSocketUrl())
|
||||
socket.binaryType = 'arraybuffer'
|
||||
|
||||
socket.onopen = () => {
|
||||
socketState.value = 'open'
|
||||
lastServerMessage.value = 'control link live'
|
||||
refreshSendLoop(true)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
lastServerMessage.value = event.data
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
socketState.value = 'closed'
|
||||
stopSendLoop()
|
||||
socket = null
|
||||
if (manualClose) {
|
||||
return
|
||||
}
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
connectSocket()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSocket() {
|
||||
manualClose = true
|
||||
stopSendLoop()
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
}
|
||||
|
||||
function startGamepadLoop() {
|
||||
if (gamepadTimer != null) {
|
||||
window.clearInterval(gamepadTimer)
|
||||
}
|
||||
pollGamepadState()
|
||||
gamepadTimer = window.setInterval(() => {
|
||||
pollGamepadState()
|
||||
}, COMMAND_SEND_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopGamepadLoop() {
|
||||
if (gamepadTimer != null) {
|
||||
window.clearInterval(gamepadTimer)
|
||||
gamepadTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function attachGlobalListeners() {
|
||||
connectSocket()
|
||||
startGamepadLoop()
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('keyup', handleKeyup)
|
||||
window.addEventListener('blur', clearKeyboardCommands)
|
||||
window.addEventListener('gamepadconnected', pollGamepadState)
|
||||
window.addEventListener('gamepaddisconnected', pollGamepadState)
|
||||
}
|
||||
|
||||
function detachGlobalListeners() {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('keyup', handleKeyup)
|
||||
window.removeEventListener('blur', clearKeyboardCommands)
|
||||
window.removeEventListener('gamepadconnected', pollGamepadState)
|
||||
window.removeEventListener('gamepaddisconnected', pollGamepadState)
|
||||
clearKeyboardCommands()
|
||||
stopGamepadLoop()
|
||||
disconnectSocket()
|
||||
}
|
||||
|
||||
function mountConsumer() {
|
||||
consumerCount += 1
|
||||
if (consumerCount === 1) {
|
||||
attachGlobalListeners()
|
||||
}
|
||||
}
|
||||
|
||||
function unmountConsumer() {
|
||||
consumerCount = Math.max(consumerCount - 1, 0)
|
||||
if (consumerCount === 0) {
|
||||
detachGlobalListeners()
|
||||
}
|
||||
}
|
||||
|
||||
const socketLabel = computed(() => {
|
||||
if (socketState.value === 'open') return 'ws open'
|
||||
if (socketState.value === 'connecting') return 'connecting'
|
||||
return 'reconnecting'
|
||||
})
|
||||
|
||||
const activeSourceLabel = computed(() => {
|
||||
if (activeSource.value === 'keyboard') return 'Keyboard'
|
||||
if (activeSource.value === 'gamepad') return 'Gamepad'
|
||||
return 'Idle'
|
||||
})
|
||||
|
||||
const controlInputModeLabel = computed(() => {
|
||||
if (controlInputMode.value === 'gamepad') return 'Gamepad'
|
||||
return 'Keyboard'
|
||||
})
|
||||
|
||||
const commandValues = computed(() => {
|
||||
const [lx, ly, lz, ax, ay, az] = resolvedCommandValues()
|
||||
return { lx, ly, lz, ax, ay, az }
|
||||
})
|
||||
|
||||
const commandLabel = computed(() => {
|
||||
const { lx, ly, az } = commandValues.value
|
||||
return `lx=${lx.toFixed(2)} ly=${ly.toFixed(2)} az=${az.toFixed(2)}`
|
||||
})
|
||||
|
||||
const commandMagnitude = computed(() => {
|
||||
const { lx, ly, az } = commandValues.value
|
||||
const limits = controlLimits.value
|
||||
return Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
Math.abs(lx) / Math.max(limits.forward, MIN_AXIS_SPEED),
|
||||
Math.abs(ly) / Math.max(limits.strafe, MIN_AXIS_SPEED),
|
||||
Math.abs(az) / Math.max(limits.turn, MIN_AXIS_SPEED),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none')
|
||||
|
||||
const keyboardKeys = computed<KeyFeedback[]>(() =>
|
||||
TRACKED_KEYS.map((code) => ({
|
||||
code,
|
||||
label: KEY_LABELS[code] ?? code,
|
||||
pressed: pressedKeys.value.has(code),
|
||||
})),
|
||||
)
|
||||
|
||||
const keyboardTurbo = computed(
|
||||
() => controlInputMode.value === 'keyboard' && (pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight')),
|
||||
)
|
||||
const controlTuning = computed<ControlTuning>(() => ({
|
||||
forward: forwardSpeed.value,
|
||||
strafe: strafeSpeed.value,
|
||||
turn: turnSpeed.value,
|
||||
turbo: turboMultiplier.value,
|
||||
}))
|
||||
const controlLimits = computed(() => ({
|
||||
forward: roundValue(forwardSpeed.value * turboMultiplier.value),
|
||||
strafe: roundValue(strafeSpeed.value * turboMultiplier.value),
|
||||
turn: roundValue(turnSpeed.value * turboMultiplier.value),
|
||||
}))
|
||||
|
||||
const gamepadButtons = computed<ButtonFeedback[]>(() =>
|
||||
GAMEPAD_BUTTON_LABELS.map((label, index) => ({
|
||||
label,
|
||||
pressed: gamepadButtonPressed.value[index] ?? false,
|
||||
})),
|
||||
)
|
||||
|
||||
const gamepadLeftStick = computed(() => ({
|
||||
x: gamepadAxes.value[0] ?? 0,
|
||||
y: gamepadAxes.value[1] ?? 0,
|
||||
}))
|
||||
|
||||
const gamepadRightStick = computed(() => ({
|
||||
x: activeTurnAxis(),
|
||||
y: gamepadAxes.value[3] ?? 0,
|
||||
}))
|
||||
|
||||
export function useControlInterface() {
|
||||
onMounted(() => {
|
||||
mountConsumer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unmountConsumer()
|
||||
})
|
||||
|
||||
return {
|
||||
controlInputMode,
|
||||
controlInputModeLabel,
|
||||
setControlInputMode,
|
||||
socketState,
|
||||
socketLabel,
|
||||
lastServerMessage,
|
||||
activeSource,
|
||||
activeSourceLabel,
|
||||
commandValues,
|
||||
commandLabel,
|
||||
commandMagnitude,
|
||||
controlTuning,
|
||||
controlLimits,
|
||||
setControlTuning,
|
||||
resetControlTuning,
|
||||
pressedKeysLabel,
|
||||
keyboardKeys,
|
||||
keyboardTurbo,
|
||||
keyboardActive: computed(() => keyboardActive()),
|
||||
gamepadSupported,
|
||||
gamepadConnected,
|
||||
gamepadName,
|
||||
gamepadIndex,
|
||||
gamepadMapping,
|
||||
gamepadButtons,
|
||||
gamepadLeftStick,
|
||||
gamepadRightStick,
|
||||
gamepadAxes,
|
||||
gamepadActive: computed(() => gamepadActiveInternal()),
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||
const video = ref<VideoStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 500)
|
||||
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000)
|
||||
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||
video.value = snapshot.video
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '数据加载失败'
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Failed to load monitoring data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -36,9 +36,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||
return errorMessage.value
|
||||
}
|
||||
if (loading.value) {
|
||||
return '正在连接 Django 后端并加载监控数据...'
|
||||
return 'Connecting to the Django backend and loading live monitoring data...'
|
||||
}
|
||||
return '页面已连接 Django 后端。GPS 与网络状态按当前页面策略轮询更新,视频区域单独按目标 30FPS 请求单帧 JPEG。'
|
||||
return 'Dashboard connected. Video, GPS, and live session telemetry refresh continuously from the unified A-side daemon.'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DashboardSnapshot } from '@/types'
|
||||
import type { DashboardSnapshot, VideoStatus } from '@/types'
|
||||
|
||||
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||
|
||||
@@ -16,6 +16,20 @@ export function fetchDashboardSnapshot() {
|
||||
return fetchJson<DashboardSnapshot>('/api/dashboard/')
|
||||
}
|
||||
|
||||
export function fetchVideoStatus() {
|
||||
return fetchJson<VideoStatus>('/api/video/status/')
|
||||
}
|
||||
|
||||
export function buildVideoFrameUrl(frameKey: number) {
|
||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
export function buildControlWebSocketUrl() {
|
||||
const url = new URL(API_BASE, window.location.origin)
|
||||
const basePath = url.pathname.replace(/\/$/, '')
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = `${basePath}/ws/control/`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
@@ -12,18 +12,171 @@ export interface GpsTelemetry {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SessionAppStats {
|
||||
connected: number
|
||||
registered?: number
|
||||
send_calls?: number
|
||||
send_bytes?: number
|
||||
send_errors?: number
|
||||
recv_calls?: number
|
||||
recv_bytes?: number
|
||||
recv_timeouts?: number
|
||||
recv_errors?: number
|
||||
last_server_error?: string
|
||||
}
|
||||
|
||||
export interface SessionKcpStats {
|
||||
connected?: number
|
||||
conv?: number
|
||||
rto_ms?: number
|
||||
srtt_ms?: number
|
||||
srttvar_ms?: number
|
||||
snd_wnd?: number
|
||||
rmt_wnd?: number
|
||||
inflight?: number
|
||||
window_limit?: number
|
||||
window_pressure_pct?: number
|
||||
snd_queue?: number
|
||||
rcv_queue?: number
|
||||
snd_buffer?: number
|
||||
out_segs_total?: number
|
||||
retrans_total?: number
|
||||
fast_retrans_total?: number
|
||||
lost_total?: number
|
||||
repeat_total?: number
|
||||
xmit_total?: number
|
||||
}
|
||||
|
||||
export interface SessionTelemetry {
|
||||
app: SessionAppStats
|
||||
kcp: SessionKcpStats
|
||||
}
|
||||
|
||||
export interface SessionTrendStats {
|
||||
snd_queue_delta: number
|
||||
snd_buffer_delta: number
|
||||
snd_queue_trend: string
|
||||
snd_buffer_trend: string
|
||||
retrans_delta: number
|
||||
fast_retrans_delta: number
|
||||
lost_delta: number
|
||||
repeat_delta: number
|
||||
out_segs_delta: number
|
||||
repair_rate_pct: number
|
||||
}
|
||||
|
||||
export interface LinkSessionTelemetry {
|
||||
peer_id: string
|
||||
connected: boolean
|
||||
updated_at: string | null
|
||||
stale: boolean
|
||||
app: SessionAppStats | null
|
||||
kcp: SessionKcpStats
|
||||
trend: SessionTrendStats
|
||||
}
|
||||
|
||||
export interface LinkAggregateTelemetry {
|
||||
online_sessions: number
|
||||
max_window_pressure_pct: number
|
||||
sum_snd_queue: number
|
||||
sum_snd_buffer: number
|
||||
sum_retrans_delta: number
|
||||
sum_out_segs_delta: number
|
||||
repair_rate_pct: number
|
||||
}
|
||||
|
||||
export interface LinkTelemetry {
|
||||
source: string
|
||||
updated_at: string | null
|
||||
stale: boolean
|
||||
aggregate: LinkAggregateTelemetry
|
||||
sessions: {
|
||||
control: LinkSessionTelemetry
|
||||
video: LinkSessionTelemetry
|
||||
}
|
||||
}
|
||||
|
||||
export interface NativeUdpIngress {
|
||||
started: boolean
|
||||
bind_addr: string
|
||||
packets_received: number
|
||||
invalid_packets: number
|
||||
last_sender: string
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface ControlArbiterStatus {
|
||||
active_source: string | null
|
||||
control_lease_remaining_ms: number
|
||||
packet_counts: Record<string, number>
|
||||
send_rate_hz: number
|
||||
source_lease_ms: number
|
||||
zero_burst_packets: number
|
||||
last_error: string
|
||||
last_sent_at_monotonic: number
|
||||
}
|
||||
|
||||
export interface ControlSenderStatus {
|
||||
backend_ready: boolean
|
||||
started: boolean
|
||||
connected: boolean
|
||||
registered: boolean
|
||||
peer_id: string
|
||||
target_peer: string
|
||||
send_count: number
|
||||
send_errors: number
|
||||
drain_errors: number
|
||||
reconnect_count: number
|
||||
last_server_error: string
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface TelemetryReceiverStatus {
|
||||
hub_connected: boolean
|
||||
hub_updated_at: string | null
|
||||
hub_stale: boolean
|
||||
last_error: string
|
||||
peer_id: string
|
||||
registered: boolean
|
||||
last_server_error: string
|
||||
reconnect_count: number
|
||||
}
|
||||
|
||||
export interface NetworkTelemetry {
|
||||
peer_status: string
|
||||
latency_ms: number
|
||||
jitter_ms: number
|
||||
retrans_pct: number
|
||||
packet_loss_pct?: number
|
||||
latency_ms: number | null
|
||||
jitter_ms: number | null
|
||||
packet_loss_pct: number | null
|
||||
tx_kbps: number
|
||||
rx_kbps: number
|
||||
signal_dbm: number | null
|
||||
transport: string
|
||||
source_mode: string
|
||||
updated_at: string
|
||||
active_control_source: string | null
|
||||
control_lease_remaining_ms: number
|
||||
combined: {
|
||||
connected_sessions: number
|
||||
send_bytes: number
|
||||
recv_bytes: number
|
||||
tx_kbps: number
|
||||
rx_kbps: number
|
||||
}
|
||||
sessions: {
|
||||
video: SessionTelemetry
|
||||
control: SessionTelemetry
|
||||
}
|
||||
links: {
|
||||
a_to_d: LinkTelemetry
|
||||
d_to_b: LinkTelemetry
|
||||
}
|
||||
telemetry_receiver: TelemetryReceiverStatus
|
||||
ingress: {
|
||||
native_udp: NativeUdpIngress
|
||||
}
|
||||
control: {
|
||||
arbiter: ControlArbiterStatus
|
||||
sender: ControlSenderStatus
|
||||
}
|
||||
}
|
||||
|
||||
export interface VideoStatus {
|
||||
@@ -33,19 +186,40 @@ export interface VideoStatus {
|
||||
fps: number
|
||||
frame_dir: string
|
||||
source_detail?: string
|
||||
timing?: {
|
||||
available: boolean
|
||||
latest_delta_ms: number | null
|
||||
delta_samples_ms: number[]
|
||||
sample_count: number
|
||||
sample_window_size: number
|
||||
timestamp_unit: string | null
|
||||
timestamp_endianness: string | null
|
||||
}
|
||||
receiver?: {
|
||||
backend_ready: boolean
|
||||
mode: string
|
||||
connected: boolean
|
||||
registered: boolean
|
||||
has_recent_frame: boolean
|
||||
frames_received: number
|
||||
latest_sequence: number | null
|
||||
reconnect_count: number
|
||||
last_server_error: string
|
||||
last_error: string
|
||||
config_path: string
|
||||
server_addr?: string
|
||||
relay_via?: string
|
||||
peer_id?: string
|
||||
buffer_bytes?: number
|
||||
timing?: {
|
||||
available: boolean
|
||||
latest_delta_ms: number | null
|
||||
delta_samples_ms: number[]
|
||||
sample_count: number
|
||||
sample_window_size: number
|
||||
timestamp_unit: string | null
|
||||
timestamp_endianness: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ControlPanel from '@/components/ControlPanel.vue'
|
||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||
import VideoPanel from '@/components/VideoPanel.vue'
|
||||
@@ -12,11 +13,11 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>机器人竞赛指挥台</h1>
|
||||
<h1>Robot Command Center</h1>
|
||||
</div>
|
||||
<p class="hero-text">
|
||||
当前版本已经接通三块核心能力:JPEG 视频流、GPS 地图定位、网络状态展示。后面接真实
|
||||
C 数据源时,前端页面不需要大改。
|
||||
The A-side daemon now owns video receive, control ingress arbitration, and live session
|
||||
telemetry in one backend process.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -25,7 +26,11 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||
</section>
|
||||
|
||||
<main class="layout">
|
||||
<VideoPanel :video="video" />
|
||||
<section class="primary-grid">
|
||||
<VideoPanel :video="video" />
|
||||
<ControlPanel />
|
||||
</section>
|
||||
|
||||
<GpsMapPanel :gps="gps" />
|
||||
<NetworkPanel :network="network" />
|
||||
</main>
|
||||
@@ -85,6 +90,19 @@ h1 {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.primary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.primary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||
const { network, errorMessage, headerStatus } = useMonitoringData({
|
||||
refreshIntervalMs: 500,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,10 +12,12 @@ const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h1>网络状态页面</h1>
|
||||
<h1>Network Telemetry</h1>
|
||||
</div>
|
||||
<p class="description">
|
||||
当前先展示模拟网络遥测数据,后续只需要把后端采集函数替换成真实 C 输出,就能保留同样的渲染界面。
|
||||
Live dual-leg OmniSocket telemetry from the A-side daemon, separating the local `A <-> D`
|
||||
sessions from the hub-reported `D <-> B` leg with queue pressure, retransmission, and stale-link
|
||||
visibility.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -69,4 +73,3 @@ h1 {
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user