Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3bb7eaae4 | |||
| f6d33d6b56 | |||
| 77681329dc |
@@ -9,23 +9,8 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
|||||||
|
|
||||||
import os
|
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
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
django_asgi_app = get_asgi_application()
|
application = 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,13 +1,7 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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'
|
SECRET_KEY = 'django-insecure-pk4scm@ifo%mao6l=j0@-$_v+pg-43^hj4a!199^)zivz-_8xu'
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
@@ -23,6 +17,7 @@ INSTALLED_APPS = [
|
|||||||
'rest_framework',
|
'rest_framework',
|
||||||
'channels',
|
'channels',
|
||||||
'monitoring',
|
'monitoring',
|
||||||
|
'control',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -91,24 +86,5 @@ USE_TZ = True
|
|||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
CORS_EXPOSE_HEADERS = [
|
|
||||||
'X-Blitz-Frame-Seq',
|
|
||||||
'X-Blitz-Backend-Received-Unix-Ns',
|
|
||||||
'X-Blitz-Frame-Hash',
|
|
||||||
'X-Blitz-BSide-Capture-To-Send-Ms',
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
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,4 +4,5 @@ from django.urls import include, path
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/', include('monitoring.urls')),
|
path('api/', include('monitoring.urls')),
|
||||||
|
path('api/control/', include('control.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
1
backend/control/__init__.py
Normal file
1
backend/control/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "control.apps.ControlConfig"
|
||||||
6
backend/control/apps.py
Normal file
6
backend/control/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ControlConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "control"
|
||||||
51
backend/control/services.py
Normal file
51
backend/control/services.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORKSPACE_ROOT = PROJECT_ROOT.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_client_api():
|
||||||
|
try:
|
||||||
|
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
|
||||||
|
except ImportError:
|
||||||
|
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
|
||||||
|
if python_dir.exists():
|
||||||
|
sys.path.insert(0, str(python_dir))
|
||||||
|
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
|
||||||
|
return OmniDaemonClient, OmniDaemonError
|
||||||
|
|
||||||
|
|
||||||
|
_OmniDaemonClient, OmniDaemonError = _load_client_api()
|
||||||
|
_daemon_client = _OmniDaemonClient()
|
||||||
|
|
||||||
|
|
||||||
|
def get_daemon_client():
|
||||||
|
return _daemon_client
|
||||||
|
|
||||||
|
|
||||||
|
class ControlProxyService:
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
return get_daemon_client().get_control_status()
|
||||||
|
|
||||||
|
def send_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
event_code: str,
|
||||||
|
drive_value: float = 1.0,
|
||||||
|
source: str = "django-api",
|
||||||
|
client_time_ms: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return get_daemon_client().send_control_event(
|
||||||
|
source=source,
|
||||||
|
event_code=event_code,
|
||||||
|
drive_value=drive_value,
|
||||||
|
client_time_ms=client_time_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
control_service = ControlProxyService()
|
||||||
9
backend/control/urls.py
Normal file
9
backend/control/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("event/", views.control_event, name="control-event"),
|
||||||
|
path("status/", views.control_status, name="control-status"),
|
||||||
|
]
|
||||||
59
backend/control/views.py
Normal file
59
backend/control/views.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rest_framework.decorators import api_view
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .services import OmniDaemonError, control_service
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def control_status(request):
|
||||||
|
try:
|
||||||
|
return Response(control_service.get_status())
|
||||||
|
except OmniDaemonError as error:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"connected": False,
|
||||||
|
"queue_depth": 0,
|
||||||
|
"last_seq_id": None,
|
||||||
|
"last_error": str(error),
|
||||||
|
"peer_id": "",
|
||||||
|
"target_peer": "",
|
||||||
|
},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
def control_event(request):
|
||||||
|
event_code = str(request.data.get("event_code", "")).strip()
|
||||||
|
if not event_code:
|
||||||
|
return Response({"error": "event_code is required"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
drive_value = float(request.data.get("drive_value", 1.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({"error": "drive_value must be numeric"}, status=400)
|
||||||
|
|
||||||
|
raw_client_time_ms = request.data.get("client_time_ms")
|
||||||
|
if raw_client_time_ms in (None, ""):
|
||||||
|
client_time_ms = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
client_time_ms = int(raw_client_time_ms)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({"error": "client_time_ms must be an integer"}, status=400)
|
||||||
|
|
||||||
|
source = str(request.data.get("source", "django-api")).strip() or "django-api"
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = control_service.send_event(
|
||||||
|
event_code=event_code,
|
||||||
|
drive_value=drive_value,
|
||||||
|
source=source,
|
||||||
|
client_time_ms=client_time_ms,
|
||||||
|
)
|
||||||
|
except OmniDaemonError as error:
|
||||||
|
return Response({"error": str(error)}, status=503)
|
||||||
|
|
||||||
|
return Response(payload, status=200 if payload.get("accepted") else 503)
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import struct
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WORKSPACE_ROOT = PROJECT_ROOT.parent
|
|
||||||
JPEG_FRAME_DIR = WORKSPACE_ROOT / "RobotDataShow" / "jpeg-frames"
|
|
||||||
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_TRAILER_ENDIANNESS = "little"
|
|
||||||
VIDEO_TRAILER_TIMESTAMP_UNIT = "ms"
|
|
||||||
VIDEO_TRAILER_TIMESTAMP_MULTIPLIER_NS = 1_000_000
|
|
||||||
VIDEO_TRAILER_TIMESTAMP_MAX_SKEW_NS = 7 * 24 * 60 * 60 * 1_000_000_000
|
|
||||||
VIDEO_TRAILER_COORDINATE_FORMAT = (
|
|
||||||
"uint64 timestamp_ms + float64 latitude + float64 longitude + uint32 capture_to_send_ms (little-endian)"
|
|
||||||
)
|
|
||||||
VIDEO_TRAILER_STRUCT = struct.Struct("<QddI")
|
|
||||||
VIDEO_TRAILER_BYTES = VIDEO_TRAILER_STRUCT.size
|
|
||||||
|
|
||||||
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)
|
|
||||||
BLITZ_RUN_DIR_RAW = os.getenv("BLITZ_RUN_DIR", "").strip()
|
|
||||||
BLITZ_RUN_DIR = Path(BLITZ_RUN_DIR_RAW).expanduser() if BLITZ_RUN_DIR_RAW else None
|
|
||||||
BLITZ_INSTANCE_ID = os.getenv("BLITZ_INSTANCE_ID", "").strip() or f"backend-{os.getpid()}"
|
|
||||||
|
|
||||||
|
|
||||||
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_ack_receiver_cfg = dict(config.get("control_ack_receiver", {}))
|
|
||||||
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")),
|
|
||||||
)
|
|
||||||
|
|
||||||
control_ack_receiver_cfg["peer_id"] = os.getenv(
|
|
||||||
"OMNISOCKET_CONTROL_ACK_RECEIVER_PEER_ID",
|
|
||||||
str(control_ack_receiver_cfg.get("peer_id", "peer-a-ctrl-ack")),
|
|
||||||
)
|
|
||||||
control_ack_receiver_cfg["expected_sender"] = os.getenv(
|
|
||||||
"OMNISOCKET_CONTROL_ACK_EXPECTED_SENDER",
|
|
||||||
str(control_ack_receiver_cfg.get("expected_sender", "peer-b-ctrl-ack")),
|
|
||||||
)
|
|
||||||
|
|
||||||
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_ack_receiver": control_ack_receiver_cfg,
|
|
||||||
"control_ingress": control_ingress_cfg,
|
|
||||||
"video_sender": video_sender_cfg,
|
|
||||||
"telemetry_receiver": telemetry_receiver_cfg,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlRunLogger:
|
|
||||||
def __init__(self, stem_env: str, default_stem: str) -> None:
|
|
||||||
explicit_path = os.getenv(stem_env, "").strip()
|
|
||||||
self._path = Path(explicit_path) if explicit_path else (
|
|
||||||
BLITZ_RUN_DIR / f"{default_stem}.{BLITZ_INSTANCE_ID}.jsonl" if BLITZ_RUN_DIR is not None else None
|
|
||||||
)
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._file = None
|
|
||||||
self._buffered_bytes = 0
|
|
||||||
self._current_bytes = 0
|
|
||||||
self._flush_bytes = self._positive_int_env("BLITZ_JSONL_FLUSH_BYTES", 262144)
|
|
||||||
self._flush_interval_ms = self._positive_int_env("BLITZ_JSONL_FLUSH_INTERVAL_MS", 1000)
|
|
||||||
self._max_bytes = self._positive_int_env("BLITZ_JSONL_ROTATE_BYTES", 134217728)
|
|
||||||
self._max_files = self._positive_int_env("BLITZ_JSONL_ROTATE_FILES", 8)
|
|
||||||
self._last_flush_monotonic_ms = self._now_ms()
|
|
||||||
|
|
||||||
if self._path is not None:
|
|
||||||
try:
|
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._file = self._path.open("a", encoding="utf-8")
|
|
||||||
self._current_bytes = self._path.stat().st_size if self._path.exists() else 0
|
|
||||||
except OSError:
|
|
||||||
self._file = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def path(self) -> str | None:
|
|
||||||
return str(self._path) if self._path is not None else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
return self._file is not None
|
|
||||||
|
|
||||||
def write(self, payload: dict[str, Any]) -> None:
|
|
||||||
if self._file is None:
|
|
||||||
return
|
|
||||||
line = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
line_bytes = len(line.encode("utf-8")) + 1
|
|
||||||
with self._lock:
|
|
||||||
if self._file is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._file.write(line)
|
|
||||||
self._file.write("\n")
|
|
||||||
self._buffered_bytes += line_bytes
|
|
||||||
self._current_bytes += line_bytes
|
|
||||||
now_ms = self._now_ms()
|
|
||||||
if (
|
|
||||||
self._buffered_bytes >= self._flush_bytes
|
|
||||||
or (self._flush_interval_ms > 0 and now_ms - self._last_flush_monotonic_ms >= self._flush_interval_ms)
|
|
||||||
):
|
|
||||||
self._flush_locked(now_ms)
|
|
||||||
if self._max_bytes > 0 and self._max_files > 0 and self._current_bytes >= self._max_bytes:
|
|
||||||
self._rotate_locked()
|
|
||||||
except OSError:
|
|
||||||
if self._file is not None:
|
|
||||||
try:
|
|
||||||
self._file.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._file = None
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
if self._file is not None:
|
|
||||||
try:
|
|
||||||
self._flush_locked(self._now_ms())
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._file.close()
|
|
||||||
self._file = None
|
|
||||||
|
|
||||||
def _flush_locked(self, now_ms: int) -> None:
|
|
||||||
if self._file is None:
|
|
||||||
return
|
|
||||||
self._file.flush()
|
|
||||||
self._buffered_bytes = 0
|
|
||||||
self._last_flush_monotonic_ms = now_ms
|
|
||||||
|
|
||||||
def _rotate_locked(self) -> None:
|
|
||||||
if self._path is None or self._file is None or self._max_files <= 0:
|
|
||||||
return
|
|
||||||
self._flush_locked(self._now_ms())
|
|
||||||
self._file.close()
|
|
||||||
self._file = None
|
|
||||||
|
|
||||||
oldest = self._path.with_name(f"{self._path.name}.{self._max_files}")
|
|
||||||
if oldest.exists():
|
|
||||||
oldest.unlink()
|
|
||||||
|
|
||||||
for index in range(self._max_files - 1, 0, -1):
|
|
||||||
src = self._path.with_name(f"{self._path.name}.{index}")
|
|
||||||
if src.exists():
|
|
||||||
dst = self._path.with_name(f"{self._path.name}.{index + 1}")
|
|
||||||
src.replace(dst)
|
|
||||||
|
|
||||||
if self._path.exists():
|
|
||||||
rotated = self._path.with_name(f"{self._path.name}.1")
|
|
||||||
self._path.replace(rotated)
|
|
||||||
|
|
||||||
self._file = self._path.open("a", encoding="utf-8")
|
|
||||||
self._buffered_bytes = 0
|
|
||||||
self._current_bytes = self._path.stat().st_size if self._path.exists() else 0
|
|
||||||
self._last_flush_monotonic_ms = self._now_ms()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _now_ms() -> int:
|
|
||||||
return int(time.monotonic() * 1000)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _positive_int_env(name: str, default: int) -> int:
|
|
||||||
raw = os.getenv(name, "").strip()
|
|
||||||
try:
|
|
||||||
value = int(raw)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
return value if value > 0 else default
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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)
|
|
||||||
|
|
||||||
@@ -1,856 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
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,
|
|
||||||
JsonlRunLogger,
|
|
||||||
ZERO_CONTROL_PAYLOAD,
|
|
||||||
WORKSPACE_ROOT,
|
|
||||||
load_omnisocket_config,
|
|
||||||
parse_host_port,
|
|
||||||
)
|
|
||||||
from .video import safe_kcp_stats
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_preview(payload: bytes, limit: int = 160) -> str:
|
|
||||||
if not payload:
|
|
||||||
return ""
|
|
||||||
preview = payload[:limit].decode("utf-8", errors="replace")
|
|
||||||
if len(payload) > limit:
|
|
||||||
return f"{preview}..."
|
|
||||||
return preview
|
|
||||||
|
|
||||||
|
|
||||||
class ControlAckTracker:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._event_logger = JsonlRunLogger("BLITZ_A_CONTROL_EVENTS_LOG_PATH", "a-control-events")
|
|
||||||
self._ack_logger = JsonlRunLogger("BLITZ_A_CONTROL_ACKS_LOG_PATH", "a-control-acks")
|
|
||||||
self._pending: dict[int, dict[str, Any]] = {}
|
|
||||||
self._latest_estimate: dict[str, Any] = {
|
|
||||||
"ack_available": False,
|
|
||||||
"updated_at": None,
|
|
||||||
"received_mono_ns": 0,
|
|
||||||
"control_loop_rtt_ms": None,
|
|
||||||
"b_recv_to_persist_ms": None,
|
|
||||||
"control_oneway_network_est_ms": None,
|
|
||||||
"control_to_persist_est_ms": None,
|
|
||||||
"sample_reason": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def register_send(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
message_id: int,
|
|
||||||
issued_at_unix_ns: int,
|
|
||||||
issued_at_mono_ns: int,
|
|
||||||
source: str,
|
|
||||||
payload: bytes,
|
|
||||||
send_call_latency_us: int,
|
|
||||||
) -> None:
|
|
||||||
event = {
|
|
||||||
"ts_unix_nano": issued_at_unix_ns,
|
|
||||||
"message_id": message_id,
|
|
||||||
"issued_at_unix_ns": issued_at_unix_ns,
|
|
||||||
"issued_at_mono_ns": issued_at_mono_ns,
|
|
||||||
"source": source,
|
|
||||||
"command_signature": payload.hex(),
|
|
||||||
"payload_size": len(payload),
|
|
||||||
"send_call_latency_us": send_call_latency_us,
|
|
||||||
}
|
|
||||||
with self._lock:
|
|
||||||
self._pending[message_id] = event
|
|
||||||
self._prune_locked(issued_at_mono_ns)
|
|
||||||
self._event_logger.write(event)
|
|
||||||
|
|
||||||
def handle_ack(self, ack_payload: dict[str, Any], received_unix_ns: int, received_mono_ns: int) -> str:
|
|
||||||
try:
|
|
||||||
message_id = int(ack_payload["message_id"])
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
return "invalid_message_id"
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
event = self._pending.pop(message_id, None)
|
|
||||||
self._prune_locked(received_mono_ns)
|
|
||||||
|
|
||||||
if event is None:
|
|
||||||
return "pending_missing"
|
|
||||||
|
|
||||||
try:
|
|
||||||
control_loop_rtt_ms = round((received_unix_ns - int(event["issued_at_unix_ns"])) / 1_000_000.0, 3)
|
|
||||||
b_recv_to_persist_ms = round(float(ack_payload.get("b_recv_to_persist_us", 0)) / 1000.0, 3)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return "invalid_timing"
|
|
||||||
|
|
||||||
control_oneway_network_est_ms = round(max(0.0, (control_loop_rtt_ms - b_recv_to_persist_ms) / 2.0), 3)
|
|
||||||
control_to_persist_est_ms = round(control_oneway_network_est_ms + b_recv_to_persist_ms, 3)
|
|
||||||
ack_record = {
|
|
||||||
"ts_unix_nano": received_unix_ns,
|
|
||||||
"received_unix_ns": received_unix_ns,
|
|
||||||
"received_mono_ns": received_mono_ns,
|
|
||||||
"message_id": message_id,
|
|
||||||
"ack_phase": str(ack_payload.get("ack_phase") or "persist_end"),
|
|
||||||
"sample_reason": str(ack_payload.get("sample_reason") or ""),
|
|
||||||
"b_recv_to_persist_us": ack_payload.get("b_recv_to_persist_us"),
|
|
||||||
"unix_send_ok": bool(ack_payload.get("unix_send_ok", False)),
|
|
||||||
"issued_at_unix_ns": event["issued_at_unix_ns"],
|
|
||||||
"source": event["source"],
|
|
||||||
"control_loop_rtt_ms": control_loop_rtt_ms,
|
|
||||||
"b_recv_to_persist_ms": b_recv_to_persist_ms,
|
|
||||||
"control_oneway_network_est_ms": control_oneway_network_est_ms,
|
|
||||||
"control_to_persist_est_ms": control_to_persist_est_ms,
|
|
||||||
}
|
|
||||||
self._ack_logger.write(ack_record)
|
|
||||||
with self._lock:
|
|
||||||
self._latest_estimate = {
|
|
||||||
"ack_available": True,
|
|
||||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(received_unix_ns / 1_000_000_000)),
|
|
||||||
"received_mono_ns": received_mono_ns,
|
|
||||||
"control_loop_rtt_ms": control_loop_rtt_ms,
|
|
||||||
"b_recv_to_persist_ms": b_recv_to_persist_ms,
|
|
||||||
"control_oneway_network_est_ms": control_oneway_network_est_ms,
|
|
||||||
"control_to_persist_est_ms": control_to_persist_est_ms,
|
|
||||||
"sample_reason": ack_record["sample_reason"],
|
|
||||||
}
|
|
||||||
return "accepted"
|
|
||||||
|
|
||||||
def get_latest_estimate(self) -> dict[str, Any]:
|
|
||||||
with self._lock:
|
|
||||||
estimate = dict(self._latest_estimate)
|
|
||||||
if int(estimate.get("received_mono_ns", 0) or 0) > 0 and time.monotonic_ns() - int(estimate["received_mono_ns"]) > 10_000_000_000:
|
|
||||||
estimate["ack_available"] = False
|
|
||||||
estimate["control_loop_rtt_ms"] = None
|
|
||||||
estimate["b_recv_to_persist_ms"] = None
|
|
||||||
estimate["control_oneway_network_est_ms"] = None
|
|
||||||
estimate["control_to_persist_est_ms"] = None
|
|
||||||
estimate["sample_reason"] = None
|
|
||||||
estimate.pop("received_mono_ns", None)
|
|
||||||
return estimate
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self._event_logger.close()
|
|
||||||
self._ack_logger.close()
|
|
||||||
|
|
||||||
def _prune_locked(self, now_mono_ns: int) -> None:
|
|
||||||
stale_ids = [
|
|
||||||
message_id
|
|
||||||
for message_id, event in self._pending.items()
|
|
||||||
if now_mono_ns - int(event.get("issued_at_mono_ns", 0)) > 60_000_000_000
|
|
||||||
]
|
|
||||||
for message_id in stale_ids:
|
|
||||||
self._pending.pop(message_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
class OmniSocketControlSender:
|
|
||||||
def __init__(self, ack_tracker: ControlAckTracker) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._ack_tracker = ack_tracker
|
|
||||||
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._supports_send_with_id = 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))
|
|
||||||
self._supports_send_with_id = hasattr(session, "send_with_id")
|
|
||||||
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
|
|
||||||
self._supports_send_with_id = False
|
|
||||||
if current is not None:
|
|
||||||
try:
|
|
||||||
current.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def send_payload(self, payload: bytes, *, source: str) -> 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
|
|
||||||
supports_send_with_id = self._supports_send_with_id
|
|
||||||
|
|
||||||
if session is None:
|
|
||||||
raise RuntimeError("control session is not available")
|
|
||||||
|
|
||||||
try:
|
|
||||||
issued_at_unix_ns = time.time_ns()
|
|
||||||
issued_at_mono_ns = time.monotonic_ns()
|
|
||||||
send_started_ns = time.perf_counter_ns()
|
|
||||||
message_id: int | None = None
|
|
||||||
if supports_send_with_id:
|
|
||||||
message_id = int(session.send_with_id(to=target_peer, data=payload))
|
|
||||||
else:
|
|
||||||
session.send(to=target_peer, data=payload)
|
|
||||||
send_call_latency_us = max(0, int((time.perf_counter_ns() - send_started_ns) / 1000))
|
|
||||||
except Exception as error:
|
|
||||||
with self._lock:
|
|
||||||
self._send_errors += 1
|
|
||||||
self._last_error = str(error)
|
|
||||||
self._reset_session(session)
|
|
||||||
raise
|
|
||||||
|
|
||||||
if message_id is not None:
|
|
||||||
self._ack_tracker.register_send(
|
|
||||||
message_id=message_id,
|
|
||||||
issued_at_unix_ns=issued_at_unix_ns,
|
|
||||||
issued_at_mono_ns=issued_at_mono_ns,
|
|
||||||
source=source,
|
|
||||||
payload=payload,
|
|
||||||
send_call_latency_us=send_call_latency_us,
|
|
||||||
)
|
|
||||||
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, source="zero_burst")
|
|
||||||
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 OmniSocketControlAckReceiver:
|
|
||||||
def __init__(self, ack_tracker: ControlAckTracker) -> None:
|
|
||||||
self._ack_tracker = ack_tracker
|
|
||||||
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._control_defaults: dict[str, Any] = {}
|
|
||||||
self._closing = threading.Event()
|
|
||||||
self._last_error = ""
|
|
||||||
self._last_server_error = ""
|
|
||||||
self._registered = False
|
|
||||||
self._reconnect_count = 0
|
|
||||||
self._ever_connected = False
|
|
||||||
self._received_messages = 0
|
|
||||||
self._received_bytes = 0
|
|
||||||
self._accepted_count = 0
|
|
||||||
self._pending_missing_count = 0
|
|
||||||
self._invalid_message_id_count = 0
|
|
||||||
self._invalid_timing_count = 0
|
|
||||||
self._unexpected_message_type_count = 0
|
|
||||||
self._unexpected_sender_count = 0
|
|
||||||
self._sender_mismatch_accepted_count = 0
|
|
||||||
self._payload_decode_errors = 0
|
|
||||||
self._last_msg_type: int | None = None
|
|
||||||
self._last_from_peer = ""
|
|
||||||
self._last_payload_preview = ""
|
|
||||||
self._last_ack_result = ""
|
|
||||||
self._load_backend()
|
|
||||||
|
|
||||||
def _load_backend(self) -> None:
|
|
||||||
try:
|
|
||||||
self._import_backend()
|
|
||||||
except Exception as error: # pragma: no cover
|
|
||||||
self._last_error = f"omnisocket import failed: {error}"
|
|
||||||
|
|
||||||
def _import_backend(self) -> None:
|
|
||||||
try:
|
|
||||||
from omnisocket import CONTROL_DEFAULTS, MSG_TYPE_ERROR, MSG_TYPE_TEXT, 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, MSG_TYPE_TEXT, Session # type: ignore
|
|
||||||
|
|
||||||
self._session_cls = Session
|
|
||||||
self._msg_type_text = MSG_TYPE_TEXT
|
|
||||||
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", {})
|
|
||||||
ack_cfg = config.get("control_ack_receiver", {})
|
|
||||||
|
|
||||||
session = self._session_cls()
|
|
||||||
session.connect(
|
|
||||||
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
|
|
||||||
peer_id=str(ack_cfg.get("peer_id", "peer-a-ctrl-ack")),
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
return session, str(ack_cfg.get("expected_sender", "peer-b-ctrl-ack"))
|
|
||||||
|
|
||||||
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="omnisocket-control-ack", daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _looks_like_control_ack(ack_payload: Any) -> bool:
|
|
||||||
if not isinstance(ack_payload, dict):
|
|
||||||
return False
|
|
||||||
if "message_id" not in ack_payload:
|
|
||||||
return False
|
|
||||||
return any(field in ack_payload for field in ("ack_phase", "b_recv_to_persist_us", "unix_send_ok", "sample_reason"))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _sender_matches(expected_sender: str, from_peer: str) -> bool:
|
|
||||||
normalized_expected = expected_sender.strip()
|
|
||||||
normalized_from = from_peer.strip()
|
|
||||||
if not normalized_expected:
|
|
||||||
return True
|
|
||||||
return normalized_from == normalized_expected
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
while not self._closing.is_set():
|
|
||||||
expected_sender = ""
|
|
||||||
try:
|
|
||||||
session, expected_sender = 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:
|
|
||||||
try:
|
|
||||||
session_stats = dict(session.stats())
|
|
||||||
except Exception:
|
|
||||||
session_stats = {}
|
|
||||||
with self._lock:
|
|
||||||
self._registered = bool(session_stats.get("registered", 0))
|
|
||||||
self._last_server_error = str(session_stats.get("last_server_error", "") or "")
|
|
||||||
continue
|
|
||||||
from_peer, msg_type, payload = result
|
|
||||||
with self._lock:
|
|
||||||
self._received_messages += 1
|
|
||||||
self._received_bytes += len(payload)
|
|
||||||
self._last_from_peer = str(from_peer or "")
|
|
||||||
self._last_msg_type = int(msg_type)
|
|
||||||
self._last_payload_preview = _payload_preview(payload)
|
|
||||||
try:
|
|
||||||
session_stats = dict(session.stats())
|
|
||||||
except Exception:
|
|
||||||
session_stats = {}
|
|
||||||
self._registered = bool(session_stats.get("registered", 0))
|
|
||||||
self._last_server_error = str(session_stats.get("last_server_error", "") or "")
|
|
||||||
if msg_type == self._msg_type_error:
|
|
||||||
with self._lock:
|
|
||||||
self._last_error = f"ack session error from {from_peer}: {payload.decode('utf-8', errors='replace')}"
|
|
||||||
self._last_ack_result = "server_error"
|
|
||||||
continue
|
|
||||||
if msg_type != self._msg_type_text:
|
|
||||||
with self._lock:
|
|
||||||
self._unexpected_message_type_count += 1
|
|
||||||
self._last_ack_result = "unexpected_message_type"
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
ack_payload = json.loads(payload.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
with self._lock:
|
|
||||||
self._payload_decode_errors += 1
|
|
||||||
self._last_ack_result = "payload_decode_error"
|
|
||||||
continue
|
|
||||||
sender_matches = self._sender_matches(expected_sender, from_peer)
|
|
||||||
if not sender_matches:
|
|
||||||
with self._lock:
|
|
||||||
self._unexpected_sender_count += 1
|
|
||||||
if not self._looks_like_control_ack(ack_payload):
|
|
||||||
with self._lock:
|
|
||||||
self._last_ack_result = "unexpected_sender"
|
|
||||||
continue
|
|
||||||
ack_result = self._ack_tracker.handle_ack(ack_payload, time.time_ns(), time.monotonic_ns())
|
|
||||||
with self._lock:
|
|
||||||
self._last_ack_result = ack_result
|
|
||||||
if ack_result == "accepted":
|
|
||||||
self._accepted_count += 1
|
|
||||||
if not sender_matches:
|
|
||||||
self._sender_mismatch_accepted_count += 1
|
|
||||||
elif ack_result == "pending_missing":
|
|
||||||
self._pending_missing_count += 1
|
|
||||||
elif ack_result == "invalid_message_id":
|
|
||||||
self._invalid_message_id_count += 1
|
|
||||||
elif ack_result == "invalid_timing":
|
|
||||||
self._invalid_timing_count += 1
|
|
||||||
except Exception as error: # pragma: no cover
|
|
||||||
if not self._closing.is_set():
|
|
||||||
with self._lock:
|
|
||||||
self._last_error = 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_status(self) -> dict[str, Any]:
|
|
||||||
config = load_omnisocket_config().get("control_ack_receiver", {})
|
|
||||||
with self._lock:
|
|
||||||
session = self._session
|
|
||||||
if session is not None:
|
|
||||||
try:
|
|
||||||
session_stats = dict(session.stats())
|
|
||||||
except Exception:
|
|
||||||
session_stats = {}
|
|
||||||
else:
|
|
||||||
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) or self._registered),
|
|
||||||
"peer_id": str(config.get("peer_id", "")),
|
|
||||||
"expected_sender": str(config.get("expected_sender", "")),
|
|
||||||
"recv_calls": int(session_stats.get("recv_calls", 0)),
|
|
||||||
"recv_bytes": int(session_stats.get("recv_bytes", 0)),
|
|
||||||
"recv_timeouts": int(session_stats.get("recv_timeouts", 0)),
|
|
||||||
"recv_errors": int(session_stats.get("recv_errors", 0)),
|
|
||||||
"received_messages": self._received_messages,
|
|
||||||
"received_bytes": self._received_bytes,
|
|
||||||
"accepted_count": self._accepted_count,
|
|
||||||
"pending_missing_count": self._pending_missing_count,
|
|
||||||
"invalid_message_id_count": self._invalid_message_id_count,
|
|
||||||
"invalid_timing_count": self._invalid_timing_count,
|
|
||||||
"unexpected_message_type_count": self._unexpected_message_type_count,
|
|
||||||
"unexpected_sender_count": self._unexpected_sender_count,
|
|
||||||
"sender_mismatch_accepted_count": self._sender_mismatch_accepted_count,
|
|
||||||
"payload_decode_errors": self._payload_decode_errors,
|
|
||||||
"last_msg_type": self._last_msg_type,
|
|
||||||
"last_from_peer": self._last_from_peer,
|
|
||||||
"last_payload_preview": self._last_payload_preview,
|
|
||||||
"last_ack_result": self._last_ack_result,
|
|
||||||
"reconnect_count": self._reconnect_count,
|
|
||||||
"last_server_error": str(session_stats.get("last_server_error", "") or self._last_server_error),
|
|
||||||
"last_error": self._last_error,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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, source=active_source)
|
|
||||||
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)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from django.urls import re_path
|
|
||||||
|
|
||||||
from .consumers import ControlConsumer
|
|
||||||
|
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
|
||||||
re_path(r"^ws/control/$", ControlConsumer.as_asgi()),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -1,52 +1,212 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import atexit
|
import json
|
||||||
|
import math
|
||||||
from .control import ControlAckTracker, ControlArbiter, NativeUdpControlIngress, OmniSocketControlAckReceiver, OmniSocketControlSender
|
import sys
|
||||||
from .telemetry import GpsDataService, HubTelemetryReceiver, NetworkTelemetryService
|
import time
|
||||||
from .video import OmniSocketVideoReceiver, VideoDisplayProbeStore, VideoFrameService
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
|
||||||
_video_receiver = OmniSocketVideoReceiver()
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
_control_ack_tracker = ControlAckTracker()
|
WORKSPACE_ROOT = PROJECT_ROOT.parent
|
||||||
_control_sender = OmniSocketControlSender(_control_ack_tracker)
|
GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
|
||||||
_control_ack_receiver = OmniSocketControlAckReceiver(_control_ack_tracker)
|
GEOSTREAM_STALE_SECONDS = 15
|
||||||
_hub_telemetry_receiver = HubTelemetryReceiver()
|
DAEMON_FRAME_URI = "omni-daemon://latest-frame"
|
||||||
_video_display_probe_store = VideoDisplayProbeStore()
|
|
||||||
|
|
||||||
control_arbiter = ControlArbiter(_control_sender)
|
|
||||||
native_control_ingress = NativeUdpControlIngress(control_arbiter)
|
|
||||||
|
|
||||||
video_service = VideoFrameService(_video_receiver, _video_display_probe_store)
|
|
||||||
gps_service = GpsDataService(_video_receiver)
|
|
||||||
network_service = NetworkTelemetryService(
|
|
||||||
_video_receiver,
|
|
||||||
_control_sender,
|
|
||||||
_control_ack_tracker,
|
|
||||||
_control_ack_receiver,
|
|
||||||
control_arbiter,
|
|
||||||
native_control_ingress,
|
|
||||||
_hub_telemetry_receiver,
|
|
||||||
_video_display_probe_store,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def shutdown_monitoring_services() -> None:
|
def utc_iso_now() -> str:
|
||||||
for closer in (
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
network_service.close,
|
|
||||||
native_control_ingress.close,
|
|
||||||
control_arbiter.close,
|
def _load_daemon_client_api():
|
||||||
_control_ack_receiver.close,
|
|
||||||
_control_ack_tracker.close,
|
|
||||||
_hub_telemetry_receiver.close,
|
|
||||||
_video_display_probe_store.close,
|
|
||||||
_video_receiver.close,
|
|
||||||
_control_sender.close,
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
closer()
|
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
|
||||||
except Exception:
|
except ImportError:
|
||||||
pass
|
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
|
||||||
|
|
||||||
|
|
||||||
atexit.register(shutdown_monitoring_services)
|
_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()
|
||||||
|
gps_service = GpsDataService()
|
||||||
|
network_service = NetworkTelemetryService()
|
||||||
|
|||||||
@@ -1,903 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import deque
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
VIDEO_TRAILER_COORDINATE_FORMAT,
|
|
||||||
WORKSPACE_ROOT,
|
|
||||||
load_omnisocket_config,
|
|
||||||
utc_iso_now,
|
|
||||||
)
|
|
||||||
from .control import ControlAckTracker, ControlArbiter, NativeUdpControlIngress, OmniSocketControlAckReceiver, OmniSocketControlSender
|
|
||||||
from .video import FrameTrailerMetadata, OmniSocketVideoReceiver, VideoDisplayProbeStore
|
|
||||||
|
|
||||||
|
|
||||||
LOCAL_SAMPLE_INTERVAL_MS = 500
|
|
||||||
TREND_HISTORY_SIZE = 10
|
|
||||||
TREND_WINDOW_SIZE = 5
|
|
||||||
BLITZ_RUNTIME_DIR = Path(os.getenv("BLITZ_RUNTIME_DIR", "/run/blitz-robot"))
|
|
||||||
WATCHDOG_STATUS_PATH = BLITZ_RUNTIME_DIR / "watchdog.status.json"
|
|
||||||
WATCHDOG_STATUS_STALE_MS = max(int(os.getenv("BLITZ_HEALTH_STALE_SEC", "15")), 1) * 1000
|
|
||||||
WATCHDOG_FAULT_REASON_MAP: dict[str, tuple[str, str | None]] = {
|
|
||||||
"": ("none", None),
|
|
||||||
"none": ("none", None),
|
|
||||||
"camera_missing": ("video_pipeline_stalled", "degraded"),
|
|
||||||
"camera_recovered": ("video_session_recovering", "recovering"),
|
|
||||||
"camera-reappeared-escalated": ("video_session_recovering", "recovering"),
|
|
||||||
"bside_status_stale": ("video_session_recovering", "recovering"),
|
|
||||||
"bside-unhealthy-escalated": ("video_session_recovering", "recovering"),
|
|
||||||
"ros_receiver_unhealthy": ("control_session_recovering", "recovering"),
|
|
||||||
"ros-unhealthy": ("control_session_recovering", "recovering"),
|
|
||||||
"network_or_robot_unreachable": ("network_or_robot_unreachable", "recovering"),
|
|
||||||
"network-recovered-ros-unhealthy": ("control_session_recovering", "recovering"),
|
|
||||||
"network-recovered-escalated": ("network_or_robot_unreachable", "recovering"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _load_optional_json(path: Path) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
with path.open("r", encoding="utf-8") as file:
|
|
||||||
payload = json.load(file)
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
return payload
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class GpsDataService:
|
|
||||||
def __init__(self, receiver: OmniSocketVideoReceiver) -> None:
|
|
||||||
self._receiver = receiver
|
|
||||||
|
|
||||||
def get_latest(self) -> dict[str, Any]:
|
|
||||||
metadata = self._receiver.get_latest_frame_metadata()
|
|
||||||
if metadata is None:
|
|
||||||
return self._build_waiting_payload()
|
|
||||||
return self._build_payload_from_metadata(metadata)
|
|
||||||
|
|
||||||
def _build_waiting_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"has_fix": False,
|
|
||||||
"utc_time": "--:--:--",
|
|
||||||
"latitude": None,
|
|
||||||
"longitude": None,
|
|
||||||
"satellites": None,
|
|
||||||
"altitude_m": None,
|
|
||||||
"coordinate_system": "WGS84",
|
|
||||||
"source_sentence": "VIDEO_TRAILER",
|
|
||||||
"raw_coordinate_format": VIDEO_TRAILER_COORDINATE_FORMAT,
|
|
||||||
"source_mode": "video-frame-trailer-waiting",
|
|
||||||
"updated_at": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _build_payload_from_metadata(self, metadata: FrameTrailerMetadata) -> dict[str, Any]:
|
|
||||||
updated_at = _utc_from_epoch(metadata.received_at) or ""
|
|
||||||
if not metadata.has_gps_fix:
|
|
||||||
return {
|
|
||||||
"has_fix": False,
|
|
||||||
"utc_time": "--:--:--",
|
|
||||||
"latitude": None,
|
|
||||||
"longitude": None,
|
|
||||||
"raw_latitude_hex": f"0x{metadata.raw_latitude_hex}",
|
|
||||||
"raw_longitude_hex": f"0x{metadata.raw_longitude_hex}",
|
|
||||||
"satellites": None,
|
|
||||||
"altitude_m": None,
|
|
||||||
"coordinate_system": "WGS84",
|
|
||||||
"source_sentence": "VIDEO_TRAILER",
|
|
||||||
"raw_coordinate_format": VIDEO_TRAILER_COORDINATE_FORMAT,
|
|
||||||
"source_mode": "video-frame-trailer-no-fix",
|
|
||||||
"updated_at": updated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp_seconds = metadata.timestamp_ns / 1_000_000_000
|
|
||||||
return {
|
|
||||||
"has_fix": True,
|
|
||||||
"utc_time": datetime.fromtimestamp(timestamp_seconds, timezone.utc).strftime("%H:%M:%S"),
|
|
||||||
"latitude": round(metadata.latitude, 6) if metadata.latitude is not None else None,
|
|
||||||
"longitude": round(metadata.longitude, 6) if metadata.longitude is not None else None,
|
|
||||||
"raw_latitude_hex": f"0x{metadata.raw_latitude_hex}",
|
|
||||||
"raw_longitude_hex": f"0x{metadata.raw_longitude_hex}",
|
|
||||||
"satellites": None,
|
|
||||||
"altitude_m": None,
|
|
||||||
"coordinate_system": "WGS84",
|
|
||||||
"source_sentence": "VIDEO_TRAILER",
|
|
||||||
"raw_coordinate_format": VIDEO_TRAILER_COORDINATE_FORMAT,
|
|
||||||
"source_mode": "video-frame-trailer",
|
|
||||||
"updated_at": updated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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")),
|
|
||||||
"min_srtt_ms": _coerce_int(raw.get("min_srtt_ms")),
|
|
||||||
"srttvar_ms": _coerce_int(raw.get("srttvar_ms")),
|
|
||||||
"last_feedback_age_ms": _coerce_int(raw.get("last_feedback_age_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_ack_tracker: ControlAckTracker,
|
|
||||||
control_ack_receiver: OmniSocketControlAckReceiver,
|
|
||||||
control_arbiter: ControlArbiter,
|
|
||||||
native_ingress: NativeUdpControlIngress,
|
|
||||||
hub_receiver: HubTelemetryReceiver,
|
|
||||||
video_display_probe_store: VideoDisplayProbeStore,
|
|
||||||
) -> None:
|
|
||||||
self._video_receiver = video_receiver
|
|
||||||
self._control_sender = control_sender
|
|
||||||
self._control_ack_tracker = control_ack_tracker
|
|
||||||
self._control_ack_receiver = control_ack_receiver
|
|
||||||
self._control_arbiter = control_arbiter
|
|
||||||
self._native_ingress = native_ingress
|
|
||||||
self._hub_receiver = hub_receiver
|
|
||||||
self._video_display_probe_store = video_display_probe_store
|
|
||||||
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._control_ack_receiver.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 _derive_robot_health(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
video_receiver_status: dict[str, Any],
|
|
||||||
local_control_registered: bool,
|
|
||||||
remote_control_fresh: bool,
|
|
||||||
remote_video_fresh: bool,
|
|
||||||
telemetry_state: dict[str, Any],
|
|
||||||
watchdog_status: dict[str, Any] | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if watchdog_status is not None:
|
|
||||||
explicit_health = self._derive_robot_health_from_watchdog(watchdog_status)
|
|
||||||
if explicit_health is not None:
|
|
||||||
return explicit_health
|
|
||||||
|
|
||||||
has_recent_frame = bool(video_receiver_status.get("has_recent_frame"))
|
|
||||||
telemetry_connected = bool(telemetry_state.get("connected"))
|
|
||||||
telemetry_stale = bool(telemetry_state.get("stale", True))
|
|
||||||
|
|
||||||
if has_recent_frame and remote_control_fresh and remote_video_fresh:
|
|
||||||
fault_reason = "none"
|
|
||||||
recovery_state = "ok"
|
|
||||||
elif not remote_control_fresh and not remote_video_fresh and not has_recent_frame:
|
|
||||||
fault_reason = "network_or_robot_unreachable"
|
|
||||||
recovery_state = "recovering" if telemetry_connected and not telemetry_stale else "degraded"
|
|
||||||
elif remote_control_fresh and not remote_video_fresh:
|
|
||||||
fault_reason = "video_session_recovering"
|
|
||||||
recovery_state = "recovering"
|
|
||||||
elif not remote_control_fresh and local_control_registered:
|
|
||||||
fault_reason = "control_session_recovering"
|
|
||||||
recovery_state = "recovering"
|
|
||||||
elif remote_control_fresh and not has_recent_frame:
|
|
||||||
fault_reason = "video_pipeline_stalled"
|
|
||||||
recovery_state = "degraded"
|
|
||||||
else:
|
|
||||||
fault_reason = "unknown"
|
|
||||||
recovery_state = "degraded"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"fault_reason": fault_reason,
|
|
||||||
"recovery_state": recovery_state,
|
|
||||||
"confidence": "derived",
|
|
||||||
"updated_at": utc_iso_now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _derive_robot_health_from_watchdog(self, watchdog_status: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
updated_at_epoch_ms = _coerce_int(watchdog_status.get("updated_at_epoch_ms"))
|
|
||||||
if updated_at_epoch_ms <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
now_epoch_ms = int(time.time() * 1000)
|
|
||||||
if now_epoch_ms - updated_at_epoch_ms > WATCHDOG_STATUS_STALE_MS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
raw_fault_reason = str(watchdog_status.get("fault_reason", "") or "")
|
|
||||||
raw_recovery_state = str(watchdog_status.get("recovery_state", "") or "")
|
|
||||||
normalized_fault_reason = "unknown"
|
|
||||||
normalized_recovery_state = raw_recovery_state or "degraded"
|
|
||||||
mapped_health = WATCHDOG_FAULT_REASON_MAP.get(raw_fault_reason)
|
|
||||||
|
|
||||||
if mapped_health is not None:
|
|
||||||
normalized_fault_reason, recovery_override = mapped_health
|
|
||||||
if recovery_override is not None:
|
|
||||||
normalized_recovery_state = recovery_override
|
|
||||||
if raw_recovery_state == "backoff":
|
|
||||||
normalized_recovery_state = "backoff"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"fault_reason": normalized_fault_reason,
|
|
||||||
"recovery_state": normalized_recovery_state,
|
|
||||||
"confidence": "derived",
|
|
||||||
"updated_at": _utc_from_epoch(updated_at_epoch_ms / 1000.0) or utc_iso_now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _derive_latency_estimate(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
links: dict[str, dict[str, Any]],
|
|
||||||
video_receiver_status: dict[str, Any],
|
|
||||||
display_probe_status: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
a_to_d_control_raw = links["a_to_d"]["sessions"]["control"]["kcp"].get("srtt_ms")
|
|
||||||
d_to_b_control_raw = links["d_to_b"]["sessions"]["control"]["kcp"].get("srtt_ms")
|
|
||||||
a_to_d_control_min_raw = links["a_to_d"]["sessions"]["control"]["kcp"].get("min_srtt_ms")
|
|
||||||
d_to_b_control_min_raw = links["d_to_b"]["sessions"]["control"]["kcp"].get("min_srtt_ms")
|
|
||||||
a_to_d_video_raw = links["a_to_d"]["sessions"]["video"]["kcp"].get("srtt_ms")
|
|
||||||
d_to_b_video_raw = links["d_to_b"]["sessions"]["video"]["kcp"].get("srtt_ms")
|
|
||||||
|
|
||||||
a_to_d_control = _coerce_float(a_to_d_control_raw) if a_to_d_control_raw is not None else None
|
|
||||||
d_to_b_control = _coerce_float(d_to_b_control_raw) if d_to_b_control_raw is not None else None
|
|
||||||
a_to_d_control_min = _coerce_float(a_to_d_control_min_raw) if a_to_d_control_min_raw is not None else None
|
|
||||||
d_to_b_control_min = _coerce_float(d_to_b_control_min_raw) if d_to_b_control_min_raw is not None else None
|
|
||||||
a_to_d_video = _coerce_float(a_to_d_video_raw) if a_to_d_video_raw is not None else None
|
|
||||||
d_to_b_video = _coerce_float(d_to_b_video_raw) if d_to_b_video_raw is not None else None
|
|
||||||
ack_estimate = self._control_ack_tracker.get_latest_estimate()
|
|
||||||
capture_to_send_raw = video_receiver_status.get("latest_capture_to_send_ms")
|
|
||||||
request_to_paint_raw = display_probe_status.get("request_to_paint_ms")
|
|
||||||
capture_to_send_ms = _coerce_float(capture_to_send_raw) if capture_to_send_raw is not None else None
|
|
||||||
request_to_paint_ms = _coerce_float(request_to_paint_raw) if request_to_paint_raw is not None else None
|
|
||||||
video_network_oneway_est_ms = (
|
|
||||||
round((a_to_d_video + d_to_b_video) / 2.0, 3)
|
|
||||||
if a_to_d_video is not None and d_to_b_video is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
video_partial_est_ms = None
|
|
||||||
if capture_to_send_ms is not None and video_network_oneway_est_ms is not None:
|
|
||||||
video_partial_est_ms = round(capture_to_send_ms + video_network_oneway_est_ms, 3)
|
|
||||||
video_e2e_est_ms = None
|
|
||||||
if video_partial_est_ms is not None and request_to_paint_ms is not None:
|
|
||||||
video_e2e_est_ms = round(video_partial_est_ms + request_to_paint_ms, 3)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"control_loop_rtt_ms": ack_estimate.get("control_loop_rtt_ms"),
|
|
||||||
"control_to_persist_est_ms": ack_estimate.get("control_to_persist_est_ms"),
|
|
||||||
"control_oneway_srtt_est_ms": (
|
|
||||||
round((a_to_d_control + d_to_b_control) / 2.0, 3)
|
|
||||||
if a_to_d_control is not None and d_to_b_control is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"control_oneway_bestcase_est_ms": (
|
|
||||||
round((a_to_d_control_min + d_to_b_control_min) / 2.0, 3)
|
|
||||||
if a_to_d_control_min is not None and d_to_b_control_min is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"video_network_oneway_est_ms": video_network_oneway_est_ms,
|
|
||||||
"video_partial_est_ms": video_partial_est_ms,
|
|
||||||
"video_e2e_est_ms": video_e2e_est_ms,
|
|
||||||
"estimate_method": {
|
|
||||||
"control": "ack_loop" if ack_estimate.get("ack_available") else "srtt_fallback",
|
|
||||||
"video": "capture_to_send+srtt/2+request_to_paint" if video_e2e_est_ms is not None else "capture_to_send+srtt/2",
|
|
||||||
},
|
|
||||||
"clock_sync_required": False,
|
|
||||||
"assumptions": [
|
|
||||||
"control one-way estimate uses ACK loop when available",
|
|
||||||
"video one-way estimate uses per-leg SRTT and local paint timing",
|
|
||||||
],
|
|
||||||
"confidence": {
|
|
||||||
"control": "derived_ack" if ack_estimate.get("ack_available") else "fallback_srtt",
|
|
||||||
"video": "derived_local_probe" if video_e2e_est_ms is not None else "partial_without_probe",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
video_receiver_status = self._video_receiver.get_status()
|
|
||||||
arbiter_status = self._control_arbiter.get_status()
|
|
||||||
ingress_status = self._native_ingress.get_status()
|
|
||||||
sender_status = self._control_sender.get_status()
|
|
||||||
ack_receiver_status = self._control_ack_receiver.get_status()
|
|
||||||
ack_status = self._control_ack_tracker.get_latest_estimate()
|
|
||||||
telemetry_state = self._hub_receiver.get_snapshot()
|
|
||||||
display_probe_status = self._video_display_probe_store.get_status()
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
self._video_receiver.update_remote_video_srtt(
|
|
||||||
_coerce_int(remote_sessions["video"]["kcp"].get("srtt_ms")) if remote_sessions["video"]["kcp"].get("srtt_ms") is not None else None
|
|
||||||
)
|
|
||||||
|
|
||||||
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"))
|
|
||||||
remote_video_fresh = bool(remote_sessions["video"].get("connected")) and not bool(remote_sessions["video"].get("stale"))
|
|
||||||
watchdog_status = _load_optional_json(WATCHDOG_STATUS_PATH)
|
|
||||||
robot_health = self._derive_robot_health(
|
|
||||||
video_receiver_status=video_receiver_status,
|
|
||||||
local_control_registered=local_control_registered,
|
|
||||||
remote_control_fresh=remote_control_fresh,
|
|
||||||
remote_video_fresh=remote_video_fresh,
|
|
||||||
telemetry_state=telemetry_state,
|
|
||||||
watchdog_status=watchdog_status,
|
|
||||||
)
|
|
||||||
latency_estimate = self._derive_latency_estimate(
|
|
||||||
links=links,
|
|
||||||
video_receiver_status=video_receiver_status,
|
|
||||||
display_probe_status=display_probe_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
"latency_estimate": latency_estimate,
|
|
||||||
"video_freshness": video_receiver_status.get("freshness", {}),
|
|
||||||
"control_ack_status": {
|
|
||||||
**ack_status,
|
|
||||||
"receiver": ack_receiver_status,
|
|
||||||
},
|
|
||||||
"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)),
|
|
||||||
},
|
|
||||||
"robot_health": robot_health,
|
|
||||||
"ingress": {
|
|
||||||
"native_udp": ingress_status,
|
|
||||||
},
|
|
||||||
"control": {
|
|
||||||
"arbiter": arbiter_status,
|
|
||||||
"sender": sender_status,
|
|
||||||
"ack_receiver": ack_receiver_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)
|
|
||||||
@@ -7,9 +7,7 @@ urlpatterns = [
|
|||||||
path("dashboard/", views.dashboard_snapshot, name="dashboard-snapshot"),
|
path("dashboard/", views.dashboard_snapshot, name="dashboard-snapshot"),
|
||||||
path("gps/latest/", views.gps_latest, name="gps-latest"),
|
path("gps/latest/", views.gps_latest, name="gps-latest"),
|
||||||
path("network/latest/", views.network_latest, name="network-latest"),
|
path("network/latest/", views.network_latest, name="network-latest"),
|
||||||
path("clock/calibrate/", views.clock_calibration, name="clock-calibration"),
|
|
||||||
path("video/status/", views.video_status, name="video-status"),
|
path("video/status/", views.video_status, name="video-status"),
|
||||||
path("video/frame/", views.video_frame, name="video-frame"),
|
path("video/frame/", views.video_frame, name="video-frame"),
|
||||||
path("video/display-probe/", views.video_display_probe, name="video-display-probe"),
|
|
||||||
path("video/stream/", views.video_stream, name="video-stream"),
|
path("video/stream/", views.video_stream, name="video-stream"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,751 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import hashlib
|
|
||||||
import math
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any, Iterator
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
JPEG_FRAME_DIR,
|
|
||||||
OMNISOCKET_CONFIG_PATH,
|
|
||||||
OMNISOCKET_FRAME_FRESH_SECONDS,
|
|
||||||
JsonlRunLogger,
|
|
||||||
VIDEO_SOURCE_MODE,
|
|
||||||
VIDEO_TIMESTAMP_SAMPLE_SIZE,
|
|
||||||
VIDEO_TRAILER_BYTES,
|
|
||||||
VIDEO_TRAILER_ENDIANNESS,
|
|
||||||
VIDEO_TRAILER_STRUCT,
|
|
||||||
VIDEO_TRAILER_TIMESTAMP_MAX_SKEW_NS,
|
|
||||||
VIDEO_TRAILER_TIMESTAMP_MULTIPLIER_NS,
|
|
||||||
VIDEO_TRAILER_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 VideoDisplayProbeStore:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._logger = JsonlRunLogger("BLITZ_A_VIDEO_DISPLAY_PROBE_LOG_PATH", "a-video-display-probe")
|
|
||||||
self._latest: VideoDisplayProbeStatus = VideoDisplayProbeStatus(
|
|
||||||
updated_at=None,
|
|
||||||
frame_seq=None,
|
|
||||||
frame_hash="",
|
|
||||||
input_to_next_fresh_frame_ms=None,
|
|
||||||
input_to_next_changed_frame_ms=None,
|
|
||||||
input_to_next_paint_ms=None,
|
|
||||||
request_to_paint_ms=None,
|
|
||||||
response_to_paint_ms=None,
|
|
||||||
backend_to_request_ms=None,
|
|
||||||
backend_to_request_ms_raw=None,
|
|
||||||
backend_to_paint_ms=None,
|
|
||||||
backend_to_paint_ms_raw=None,
|
|
||||||
browser_backend_clock_offset_ms=None,
|
|
||||||
browser_backend_clock_rtt_ms=None,
|
|
||||||
browser_backend_clock_sample_count=0,
|
|
||||||
browser_backend_clock_calibrated_at=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def record_event(self, payload: dict[str, Any]) -> None:
|
|
||||||
backend_received_unix_ns = payload.get("backend_received_unix_ns")
|
|
||||||
request_started_unix_ms = payload.get("request_started_unix_ms")
|
|
||||||
response_received_unix_ms = payload.get("response_received_unix_ms")
|
|
||||||
paint_unix_ms = payload.get("paint_unix_ms")
|
|
||||||
browser_backend_clock_offset_ms = self._coerce_float(payload.get("browser_backend_clock_offset_ms"))
|
|
||||||
browser_backend_clock_rtt_ms = self._coerce_float(payload.get("browser_backend_clock_rtt_ms"))
|
|
||||||
browser_backend_clock_sample_count = self._coerce_int(payload.get("browser_backend_clock_sample_count"))
|
|
||||||
browser_backend_clock_calibrated_at = self._coerce_text(payload.get("browser_backend_clock_calibrated_at"))
|
|
||||||
request_to_paint_ms = self._duration_ms(paint_unix_ms, request_started_unix_ms, clamp_floor_zero=True)
|
|
||||||
response_to_paint_ms = self._duration_ms(paint_unix_ms, response_received_unix_ms, clamp_floor_zero=True)
|
|
||||||
backend_received_unix_ms = None
|
|
||||||
try:
|
|
||||||
if backend_received_unix_ns is not None:
|
|
||||||
backend_received_unix_ms = int(backend_received_unix_ns) / 1_000_000.0
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
backend_received_unix_ms = None
|
|
||||||
backend_received_browser_unix_ms = None
|
|
||||||
if backend_received_unix_ms is not None and browser_backend_clock_offset_ms is not None:
|
|
||||||
backend_received_browser_unix_ms = round(backend_received_unix_ms + browser_backend_clock_offset_ms, 3)
|
|
||||||
backend_to_request_ms_raw = self._duration_ms(request_started_unix_ms, backend_received_unix_ms, clamp_floor_zero=False)
|
|
||||||
backend_to_paint_ms_raw = self._duration_ms(paint_unix_ms, backend_received_unix_ms, clamp_floor_zero=False)
|
|
||||||
backend_to_request_ms = self._duration_ms(
|
|
||||||
request_started_unix_ms,
|
|
||||||
backend_received_browser_unix_ms,
|
|
||||||
clamp_floor_zero=True,
|
|
||||||
)
|
|
||||||
backend_to_paint_ms = self._duration_ms(
|
|
||||||
paint_unix_ms,
|
|
||||||
backend_received_browser_unix_ms,
|
|
||||||
clamp_floor_zero=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = VideoDisplayProbeStatus(
|
|
||||||
updated_at=self._coerce_text(payload.get("updated_at")),
|
|
||||||
frame_seq=int(payload["frame_seq"]) if payload.get("frame_seq") is not None else None,
|
|
||||||
frame_hash=str(payload.get("frame_hash") or ""),
|
|
||||||
input_to_next_fresh_frame_ms=self._coerce_float(payload.get("input_to_next_fresh_frame_ms")),
|
|
||||||
input_to_next_changed_frame_ms=self._coerce_float(payload.get("input_to_next_changed_frame_ms")),
|
|
||||||
input_to_next_paint_ms=self._coerce_float(payload.get("input_to_next_paint_ms")),
|
|
||||||
request_to_paint_ms=request_to_paint_ms,
|
|
||||||
response_to_paint_ms=response_to_paint_ms,
|
|
||||||
backend_to_request_ms=backend_to_request_ms,
|
|
||||||
backend_to_request_ms_raw=backend_to_request_ms_raw,
|
|
||||||
backend_to_paint_ms=backend_to_paint_ms,
|
|
||||||
backend_to_paint_ms_raw=backend_to_paint_ms_raw,
|
|
||||||
browser_backend_clock_offset_ms=browser_backend_clock_offset_ms,
|
|
||||||
browser_backend_clock_rtt_ms=browser_backend_clock_rtt_ms,
|
|
||||||
browser_backend_clock_sample_count=browser_backend_clock_sample_count,
|
|
||||||
browser_backend_clock_calibrated_at=browser_backend_clock_calibrated_at,
|
|
||||||
)
|
|
||||||
logged_payload = dict(payload)
|
|
||||||
logged_payload.update(
|
|
||||||
{
|
|
||||||
"request_to_paint_ms": request_to_paint_ms,
|
|
||||||
"response_to_paint_ms": response_to_paint_ms,
|
|
||||||
"backend_received_browser_unix_ms": backend_received_browser_unix_ms,
|
|
||||||
"backend_to_request_ms": backend_to_request_ms,
|
|
||||||
"backend_to_request_ms_raw": backend_to_request_ms_raw,
|
|
||||||
"backend_to_paint_ms": backend_to_paint_ms,
|
|
||||||
"backend_to_paint_ms_raw": backend_to_paint_ms_raw,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
self._latest = status
|
|
||||||
self._logger.write(logged_payload)
|
|
||||||
|
|
||||||
def get_status(self) -> dict[str, Any]:
|
|
||||||
with self._lock:
|
|
||||||
latest = self._latest
|
|
||||||
return {
|
|
||||||
"updated_at": latest.updated_at,
|
|
||||||
"frame_seq": latest.frame_seq,
|
|
||||||
"frame_hash": latest.frame_hash,
|
|
||||||
"input_to_next_fresh_frame_ms": latest.input_to_next_fresh_frame_ms,
|
|
||||||
"input_to_next_changed_frame_ms": latest.input_to_next_changed_frame_ms,
|
|
||||||
"input_to_next_paint_ms": latest.input_to_next_paint_ms,
|
|
||||||
"request_to_paint_ms": latest.request_to_paint_ms,
|
|
||||||
"response_to_paint_ms": latest.response_to_paint_ms,
|
|
||||||
"backend_to_request_ms": latest.backend_to_request_ms,
|
|
||||||
"backend_to_request_ms_raw": latest.backend_to_request_ms_raw,
|
|
||||||
"backend_to_paint_ms": latest.backend_to_paint_ms,
|
|
||||||
"backend_to_paint_ms_raw": latest.backend_to_paint_ms_raw,
|
|
||||||
"browser_backend_clock_offset_ms": latest.browser_backend_clock_offset_ms,
|
|
||||||
"browser_backend_clock_rtt_ms": latest.browser_backend_clock_rtt_ms,
|
|
||||||
"browser_backend_clock_sample_count": latest.browser_backend_clock_sample_count,
|
|
||||||
"browser_backend_clock_calibrated_at": latest.browser_backend_clock_calibrated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self._logger.close()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _coerce_float(value: Any) -> float | None:
|
|
||||||
try:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return round(float(value), 3)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _coerce_int(value: Any) -> int:
|
|
||||||
try:
|
|
||||||
if value is None:
|
|
||||||
return 0
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _coerce_text(value: Any) -> str | None:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
return text or None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _duration_ms(cls, end_ms: Any, start_ms: Any, *, clamp_floor_zero: bool) -> float | None:
|
|
||||||
end_value = cls._coerce_float(end_ms)
|
|
||||||
start_value = cls._coerce_float(start_ms)
|
|
||||||
if end_value is None or start_value is None:
|
|
||||||
return None
|
|
||||||
delta = round(end_value - start_value, 3)
|
|
||||||
if clamp_floor_zero:
|
|
||||||
delta = max(0.0, delta)
|
|
||||||
return delta
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FrameTrailerMetadata:
|
|
||||||
timestamp_ns: int
|
|
||||||
latitude: float | None
|
|
||||||
longitude: float | None
|
|
||||||
capture_to_send_ms: int
|
|
||||||
raw_latitude_hex: str
|
|
||||||
raw_longitude_hex: str
|
|
||||||
received_at: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_gps_fix(self) -> bool:
|
|
||||||
return self.latitude is not None and self.longitude is not None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VideoDisplayProbeStatus:
|
|
||||||
updated_at: str | None
|
|
||||||
frame_seq: int | None
|
|
||||||
frame_hash: str
|
|
||||||
input_to_next_fresh_frame_ms: float | None
|
|
||||||
input_to_next_changed_frame_ms: float | None
|
|
||||||
input_to_next_paint_ms: float | None
|
|
||||||
request_to_paint_ms: float | None
|
|
||||||
response_to_paint_ms: float | None
|
|
||||||
backend_to_request_ms: float | None
|
|
||||||
backend_to_request_ms_raw: float | None
|
|
||||||
backend_to_paint_ms: float | None
|
|
||||||
backend_to_paint_ms_raw: float | None
|
|
||||||
browser_backend_clock_offset_ms: float | None
|
|
||||||
browser_backend_clock_rtt_ms: float | None
|
|
||||||
browser_backend_clock_sample_count: int
|
|
||||||
browser_backend_clock_calibrated_at: str | None
|
|
||||||
|
|
||||||
|
|
||||||
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_backend_received_unix_ns = 0
|
|
||||||
self._latest_backend_received_mono_ns = 0
|
|
||||||
self._latest_sequence: int | None = None
|
|
||||||
self._latest_metadata: FrameTrailerMetadata | None = None
|
|
||||||
self._latest_sender_clock_delta_ms_raw: float | None = None
|
|
||||||
self._latest_timestamp_unit: str | None = None
|
|
||||||
self._latest_timestamp_endianness: str | None = None
|
|
||||||
self._sender_clock_delta_samples_ms_raw: deque[float] = deque(maxlen=VIDEO_TIMESTAMP_SAMPLE_SIZE)
|
|
||||||
self._latest_frame_hash = ""
|
|
||||||
self._latest_frame_bytes = 0
|
|
||||||
self._last_frame_hash = ""
|
|
||||||
self._last_sequence: int | None = None
|
|
||||||
self._last_backend_received_mono_ns = 0
|
|
||||||
self._interarrival_ms_samples: deque[float] = deque(maxlen=120)
|
|
||||||
self._repeat_samples: deque[int] = deque(maxlen=120)
|
|
||||||
self._skip_samples: deque[int] = deque(maxlen=120)
|
|
||||||
self._freeze_samples_ms: deque[float] = deque(maxlen=120)
|
|
||||||
self._current_stale_frame_run_length = 0
|
|
||||||
self._latest_remote_video_srtt_ms: int | None = None
|
|
||||||
self._frames_received = 0
|
|
||||||
self._last_error = ""
|
|
||||||
self._reconnect_count = 0
|
|
||||||
self._ever_connected = False
|
|
||||||
self._closing = threading.Event()
|
|
||||||
self._frame_recv_logger = JsonlRunLogger("BLITZ_A_VIDEO_FRAME_RECV_LOG_PATH", "a-video-frame-recv")
|
|
||||||
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_TRAILER_BYTES + 2
|
|
||||||
and jpeg_payload[-(VIDEO_TRAILER_BYTES + 2) : -VIDEO_TRAILER_BYTES] == b"\xff\xd9"
|
|
||||||
):
|
|
||||||
return jpeg_payload[:-VIDEO_TRAILER_BYTES], jpeg_payload[-VIDEO_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_metadata(self, frame: bytes, received_at: float | None = None) -> FrameTrailerMetadata | None:
|
|
||||||
trailer = self._extract_frame_tail(frame)
|
|
||||||
if len(trailer) != VIDEO_TRAILER_BYTES:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
timestamp_ms, latitude, longitude, capture_to_send_ms = VIDEO_TRAILER_STRUCT.unpack(trailer)
|
|
||||||
except struct.error:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if timestamp_ms <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
timestamp_ns = timestamp_ms * VIDEO_TRAILER_TIMESTAMP_MULTIPLIER_NS
|
|
||||||
if abs(time.time_ns() - timestamp_ns) > VIDEO_TRAILER_TIMESTAMP_MAX_SKEW_NS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
gps_fix_available = (
|
|
||||||
math.isfinite(latitude)
|
|
||||||
and math.isfinite(longitude)
|
|
||||||
and (-90.0 <= latitude <= 90.0)
|
|
||||||
and (-180.0 <= longitude <= 180.0)
|
|
||||||
and not (abs(latitude) < 1e-9 and abs(longitude) < 1e-9)
|
|
||||||
)
|
|
||||||
|
|
||||||
return FrameTrailerMetadata(
|
|
||||||
timestamp_ns=timestamp_ns,
|
|
||||||
latitude=latitude if gps_fix_available else None,
|
|
||||||
longitude=longitude if gps_fix_available else None,
|
|
||||||
capture_to_send_ms=int(capture_to_send_ms),
|
|
||||||
raw_latitude_hex=trailer[8:16].hex(),
|
|
||||||
raw_longitude_hex=trailer[16:24].hex(),
|
|
||||||
received_at=received_at if received_at is not None else time.time(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _has_fresh_frame_locked(self) -> bool:
|
|
||||||
return self._latest_frame is not None and (
|
|
||||||
time.time() - self._latest_received_at <= OMNISOCKET_FRAME_FRESH_SECONDS
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_remote_video_srtt(self, srtt_ms: int | None) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._latest_remote_video_srtt_ms = srtt_ms
|
|
||||||
|
|
||||||
def _freshness_payload_locked(self) -> dict[str, Any]:
|
|
||||||
interarrival_samples = list(self._interarrival_ms_samples)
|
|
||||||
repeat_samples = list(self._repeat_samples)
|
|
||||||
skip_samples = list(self._skip_samples)
|
|
||||||
freeze_samples_ms = list(self._freeze_samples_ms)
|
|
||||||
|
|
||||||
inter_frame_avg_ms = round(sum(interarrival_samples) / len(interarrival_samples), 3) if interarrival_samples else None
|
|
||||||
if interarrival_samples:
|
|
||||||
ordered = sorted(interarrival_samples)
|
|
||||||
p95_index = min(len(ordered) - 1, max(0, math.ceil(len(ordered) * 0.95) - 1))
|
|
||||||
inter_frame_p95_ms = round(ordered[p95_index], 3)
|
|
||||||
else:
|
|
||||||
inter_frame_p95_ms = None
|
|
||||||
|
|
||||||
repeated_frame_ratio = round(sum(repeat_samples) / len(repeat_samples), 4) if repeat_samples else 0.0
|
|
||||||
total_skip = sum(skip_samples)
|
|
||||||
expected_frames = len(skip_samples) + total_skip
|
|
||||||
skip_ratio = round(total_skip / expected_frames, 4) if expected_frames > 0 else 0.0
|
|
||||||
longest_freeze_ms = round(max(freeze_samples_ms), 3) if freeze_samples_ms else 0.0
|
|
||||||
return {
|
|
||||||
"inter_frame_avg_ms": inter_frame_avg_ms,
|
|
||||||
"inter_frame_p95_ms": inter_frame_p95_ms,
|
|
||||||
"repeated_frame_ratio": repeated_frame_ratio,
|
|
||||||
"skip_ratio": skip_ratio,
|
|
||||||
"longest_freeze_ms": longest_freeze_ms,
|
|
||||||
"stale_frame_run_length": self._current_stale_frame_run_length,
|
|
||||||
"relative_freshness_lag_frames": self._current_stale_frame_run_length + (skip_samples[-1] if skip_samples else 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _frame_headers_locked(self) -> dict[str, str]:
|
|
||||||
capture_to_send_ms = self._latest_metadata.capture_to_send_ms if self._latest_metadata is not None else None
|
|
||||||
headers: dict[str, str] = {}
|
|
||||||
if self._latest_sequence is not None:
|
|
||||||
headers["X-Blitz-Frame-Seq"] = str(self._latest_sequence)
|
|
||||||
if self._latest_backend_received_unix_ns > 0:
|
|
||||||
headers["X-Blitz-Backend-Received-Unix-Ns"] = str(self._latest_backend_received_unix_ns)
|
|
||||||
if self._latest_frame_hash:
|
|
||||||
headers["X-Blitz-Frame-Hash"] = self._latest_frame_hash
|
|
||||||
if capture_to_send_ms is not None:
|
|
||||||
headers["X-Blitz-BSide-Capture-To-Send-Ms"] = str(capture_to_send_ms)
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def get_latest_frame_headers(self) -> dict[str, str]:
|
|
||||||
snapshot = self.get_latest_frame_snapshot()
|
|
||||||
return snapshot[1] if snapshot is not None else {}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
received_at = time.time()
|
|
||||||
received_unix_ns = time.time_ns()
|
|
||||||
received_mono_ns = time.monotonic_ns()
|
|
||||||
frame_metadata = self._extract_frame_metadata(frame, received_at=received_at)
|
|
||||||
sender_clock_delta_ms_raw = None
|
|
||||||
if frame_metadata is not None:
|
|
||||||
sender_clock_delta_ms_raw = round((received_unix_ns - frame_metadata.timestamp_ns) / 1_000_000, 3)
|
|
||||||
unit = VIDEO_TRAILER_TIMESTAMP_UNIT
|
|
||||||
endianness = VIDEO_TRAILER_ENDIANNESS
|
|
||||||
else:
|
|
||||||
unit = None
|
|
||||||
endianness = None
|
|
||||||
frame_sequence = self._extract_sequence(frame)
|
|
||||||
frame_hash = hashlib.blake2s(jpeg_frame, digest_size=8).hexdigest()
|
|
||||||
local_kcp = safe_kcp_stats(session) if self._frame_recv_logger.enabled else {}
|
|
||||||
frame_log_record: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
interarrival_ms = None
|
|
||||||
if self._last_backend_received_mono_ns > 0:
|
|
||||||
interarrival_ms = round((received_mono_ns - self._last_backend_received_mono_ns) / 1_000_000, 3)
|
|
||||||
self._interarrival_ms_samples.append(interarrival_ms)
|
|
||||||
|
|
||||||
sequence_gap = 0
|
|
||||||
if frame_sequence is not None and self._last_sequence is not None and frame_sequence > self._last_sequence:
|
|
||||||
sequence_gap = max(0, frame_sequence - self._last_sequence - 1)
|
|
||||||
|
|
||||||
repeat_flag = bool(self._last_frame_hash) and frame_hash == self._last_frame_hash
|
|
||||||
self._repeat_samples.append(1 if repeat_flag else 0)
|
|
||||||
self._skip_samples.append(sequence_gap)
|
|
||||||
if repeat_flag:
|
|
||||||
self._current_stale_frame_run_length += 1
|
|
||||||
else:
|
|
||||||
self._current_stale_frame_run_length = 0
|
|
||||||
if interarrival_ms is not None and repeat_flag:
|
|
||||||
self._freeze_samples_ms.append(interarrival_ms)
|
|
||||||
elif interarrival_ms is not None:
|
|
||||||
self._freeze_samples_ms.append(0.0)
|
|
||||||
|
|
||||||
self._latest_frame = jpeg_frame
|
|
||||||
self._latest_received_at = received_at
|
|
||||||
self._latest_backend_received_unix_ns = received_unix_ns
|
|
||||||
self._latest_backend_received_mono_ns = received_mono_ns
|
|
||||||
self._latest_sequence = frame_sequence
|
|
||||||
self._last_sequence = frame_sequence
|
|
||||||
self._latest_metadata = frame_metadata
|
|
||||||
self._latest_sender_clock_delta_ms_raw = sender_clock_delta_ms_raw
|
|
||||||
self._latest_timestamp_unit = unit
|
|
||||||
self._latest_timestamp_endianness = endianness
|
|
||||||
self._latest_frame_hash = frame_hash
|
|
||||||
self._latest_frame_bytes = len(jpeg_frame)
|
|
||||||
self._last_frame_hash = frame_hash
|
|
||||||
self._last_backend_received_mono_ns = received_mono_ns
|
|
||||||
if sender_clock_delta_ms_raw is not None:
|
|
||||||
self._sender_clock_delta_samples_ms_raw.append(sender_clock_delta_ms_raw)
|
|
||||||
self._frames_received += 1
|
|
||||||
|
|
||||||
if self._frame_recv_logger.enabled:
|
|
||||||
frame_log_record = {
|
|
||||||
"ts_unix_nano": received_unix_ns,
|
|
||||||
"frame_seq": frame_sequence,
|
|
||||||
"backend_received_unix_ns": received_unix_ns,
|
|
||||||
"backend_received_mono_ns": received_mono_ns,
|
|
||||||
"jpeg_bytes": len(jpeg_frame),
|
|
||||||
"interarrival_ms": interarrival_ms,
|
|
||||||
"sequence_gap": sequence_gap,
|
|
||||||
"repeat_flag": repeat_flag,
|
|
||||||
"skip_count": sequence_gap,
|
|
||||||
"frame_hash": frame_hash,
|
|
||||||
"a_to_d_video_srtt_ms": local_kcp.get("srtt_ms"),
|
|
||||||
"d_to_b_video_srtt_ms": self._latest_remote_video_srtt_ms,
|
|
||||||
"b_side_capture_to_send_ms": frame_metadata.capture_to_send_ms if frame_metadata is not None else None,
|
|
||||||
"sender_clock_delta_ms_raw": sender_clock_delta_ms_raw,
|
|
||||||
}
|
|
||||||
if frame_log_record is not None:
|
|
||||||
self._frame_recv_logger.write(frame_log_record)
|
|
||||||
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:
|
|
||||||
snapshot = self.get_latest_frame_snapshot()
|
|
||||||
return snapshot[0] if snapshot is not None else None
|
|
||||||
|
|
||||||
def get_latest_frame_snapshot(self) -> tuple[bytes, dict[str, str]] | None:
|
|
||||||
self.ensure_started()
|
|
||||||
with self._lock:
|
|
||||||
if not self._has_fresh_frame_locked():
|
|
||||||
return None
|
|
||||||
if self._latest_frame is None:
|
|
||||||
return None
|
|
||||||
return self._latest_frame, self._frame_headers_locked()
|
|
||||||
|
|
||||||
def get_latest_frame_metadata(self) -> FrameTrailerMetadata | None:
|
|
||||||
self.ensure_started()
|
|
||||||
with self._lock:
|
|
||||||
if not self._has_fresh_frame_locked():
|
|
||||||
return None
|
|
||||||
return self._latest_metadata
|
|
||||||
|
|
||||||
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._has_fresh_frame_locked()
|
|
||||||
freshness_status = self._freshness_payload_locked()
|
|
||||||
if has_recent_frame and self._latest_sender_clock_delta_ms_raw is not None:
|
|
||||||
timing_status = {
|
|
||||||
"available": True,
|
|
||||||
"sender_clock_delta_ms_raw": self._latest_sender_clock_delta_ms_raw,
|
|
||||||
"sender_clock_delta_samples_ms_raw": list(reversed(self._sender_clock_delta_samples_ms_raw)),
|
|
||||||
"sample_count": len(self._sender_clock_delta_samples_ms_raw),
|
|
||||||
"sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE,
|
|
||||||
"timestamp_unit": self._latest_timestamp_unit,
|
|
||||||
"timestamp_endianness": self._latest_timestamp_endianness,
|
|
||||||
"unsynced_clock": True,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
timing_status = {
|
|
||||||
"available": False,
|
|
||||||
"sender_clock_delta_ms_raw": None,
|
|
||||||
"sender_clock_delta_samples_ms_raw": [],
|
|
||||||
"sample_count": 0,
|
|
||||||
"sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE,
|
|
||||||
"timestamp_unit": None,
|
|
||||||
"timestamp_endianness": None,
|
|
||||||
"unsynced_clock": True,
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
"latest_frame_hash": self._latest_frame_hash,
|
|
||||||
"latest_backend_received_unix_ns": self._latest_backend_received_unix_ns or None,
|
|
||||||
"latest_backend_received_mono_ns": self._latest_backend_received_mono_ns or None,
|
|
||||||
"latest_frame_bytes": self._latest_frame_bytes,
|
|
||||||
"latest_capture_to_send_ms": self._latest_metadata.capture_to_send_ms if self._latest_metadata is not None else None,
|
|
||||||
"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,
|
|
||||||
"freshness": freshness_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)
|
|
||||||
self._frame_recv_logger.close()
|
|
||||||
|
|
||||||
|
|
||||||
class VideoFrameService:
|
|
||||||
def __init__(self, receiver: OmniSocketVideoReceiver, display_probe_store: VideoDisplayProbeStore) -> None:
|
|
||||||
self._receiver = receiver
|
|
||||||
self._display_probe_store = display_probe_store
|
|
||||||
|
|
||||||
def get_status(self) -> dict[str, Any]:
|
|
||||||
receiver_status = self._receiver.get_status()
|
|
||||||
receiver_frame = self._receiver.get_latest_frame()
|
|
||||||
display_probe_status = self._display_probe_store.get_status()
|
|
||||||
|
|
||||||
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"],
|
|
||||||
"freshness": receiver_status.get("freshness", {}),
|
|
||||||
"display_probe": display_probe_status,
|
|
||||||
}
|
|
||||||
|
|
||||||
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"],
|
|
||||||
"freshness": receiver_status.get("freshness", {}),
|
|
||||||
"display_probe": display_probe_status,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 get_next_frame_with_headers(self) -> tuple[bytes, dict[str, str]]:
|
|
||||||
snapshot = self._receiver.get_latest_frame_snapshot()
|
|
||||||
if snapshot is not None:
|
|
||||||
return snapshot
|
|
||||||
raise RuntimeError("no live OmniSocket JPEG frame is currently available")
|
|
||||||
|
|
||||||
def get_latest_frame_headers(self) -> dict[str, str]:
|
|
||||||
return self._receiver.get_latest_frame_headers()
|
|
||||||
|
|
||||||
def record_display_probe(self, payload: dict[str, Any]) -> None:
|
|
||||||
self._display_probe_store.record_event(payload)
|
|
||||||
|
|
||||||
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,9 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.http import HttpResponse, StreamingHttpResponse
|
from django.http import HttpResponse, StreamingHttpResponse
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -32,40 +28,31 @@ def network_latest(request):
|
|||||||
return Response(network_service.get_latest())
|
return Response(network_service.get_latest())
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
|
||||||
def clock_calibration(request):
|
|
||||||
server_received_unix_ns = time.time_ns()
|
|
||||||
server_sent_unix_ns = time.time_ns()
|
|
||||||
response = Response(
|
|
||||||
{
|
|
||||||
"server_received_unix_ms": round(server_received_unix_ns / 1_000_000.0, 3),
|
|
||||||
"server_sent_unix_ms": round(server_sent_unix_ns / 1_000_000.0, 3),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def video_status(request):
|
def video_status(request):
|
||||||
return Response(video_service.get_status())
|
return Response(video_service.get_status())
|
||||||
|
|
||||||
|
|
||||||
def video_frame(request):
|
def video_frame(request):
|
||||||
try:
|
|
||||||
frame, headers = video_service.get_next_frame_with_headers()
|
|
||||||
except (FileNotFoundError, RuntimeError) as error:
|
|
||||||
status = video_service.get_status()
|
status = video_service.get_status()
|
||||||
|
if not status["available"]:
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status.get("source_detail") or str(error),
|
status.get("source_detail") or f"JPEG frame directory not found: {status['frame_dir']}",
|
||||||
|
status=503,
|
||||||
|
content_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = video_service.get_next_frame()
|
||||||
|
except (FileNotFoundError, RuntimeError) as error:
|
||||||
|
return HttpResponse(
|
||||||
|
str(error),
|
||||||
status=503,
|
status=503,
|
||||||
content_type="text/plain; charset=utf-8",
|
content_type="text/plain; charset=utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = HttpResponse(frame, content_type="image/jpeg")
|
response = HttpResponse(frame, content_type="image/jpeg")
|
||||||
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
for key, value in headers.items():
|
|
||||||
response[key] = value
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -89,18 +76,3 @@ def video_stream(request):
|
|||||||
)
|
)
|
||||||
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@api_view(["POST"])
|
|
||||||
def video_display_probe(request):
|
|
||||||
try:
|
|
||||||
payload = json.loads(request.body.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
return Response({"detail": "invalid json"}, status=400)
|
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return Response({"detail": "expected json object"}, status=400)
|
|
||||||
|
|
||||||
video_service.record_display_probe(payload)
|
|
||||||
return Response({"ok": True})
|
|
||||||
|
|||||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Django>=5,<6
|
||||||
|
djangorestframework>=3.15,<4
|
||||||
|
django-cors-headers>=4,<5
|
||||||
|
channels>=4,<5
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
transport:
|
transport:
|
||||||
server_addr: ""
|
server_addr: ""
|
||||||
relay_via: "106.55.173.235:10909"
|
relay_via: 106.55.173.235:10909
|
||||||
bind_ip: ""
|
bind_ip: ""
|
||||||
bind_device: ""
|
bind_device: ""
|
||||||
|
|
||||||
@@ -8,25 +8,6 @@ video_receiver:
|
|||||||
peer_id: "peer-a-video"
|
peer_id: "peer-a-video"
|
||||||
buffer_bytes: 1048576
|
buffer_bytes: 1048576
|
||||||
|
|
||||||
control_sender:
|
|
||||||
peer_id: "peer-a-ctrl"
|
|
||||||
target_peer: "peer-b-ctrl"
|
|
||||||
|
|
||||||
control_ack_receiver:
|
|
||||||
peer_id: "peer-a-ctrl-ack"
|
|
||||||
expected_sender: "peer-b-ctrl-ack"
|
|
||||||
|
|
||||||
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:
|
video_sender:
|
||||||
peer_id: "peer-b-video"
|
peer_id: "peer-b-video"
|
||||||
target_peer: "peer-a-video"
|
target_peer: "peer-a-video"
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/locale'
|
const navItems = [
|
||||||
|
{ to: '/', label: '总览' },
|
||||||
const { t, toggleLocale, nextLocaleLabel } = useLocale()
|
{ to: '/video', label: '视频流' },
|
||||||
|
{ to: '/map', label: '地图定位' },
|
||||||
const navItems = computed(() => [
|
{ to: '/network', label: '网络状态' },
|
||||||
{ to: '/', label: t('app.nav.overview') },
|
]
|
||||||
{ to: '/video', label: t('app.nav.video') },
|
|
||||||
{ to: '/map', label: t('app.nav.map') },
|
|
||||||
{ to: '/network', label: t('app.nav.network') },
|
|
||||||
])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,12 +15,11 @@ const navItems = computed(() => [
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<p class="brand-mark">RCC</p>
|
<p class="brand-mark">RCC</p>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ t('app.brandTitle') }}</strong>
|
<strong>Robot Command Center</strong>
|
||||||
<span>{{ t('app.brandSubtitle') }}</span>
|
<span>机器人竞赛指挥台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
@@ -36,11 +30,6 @@ const navItems = computed(() => [
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button type="button" class="locale-button" @click="toggleLocale">
|
|
||||||
{{ nextLocaleLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="page-body">
|
<main class="page-body">
|
||||||
@@ -74,26 +63,24 @@ const navItems = computed(() => [
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
width: min(1440px, calc(100% - 32px));
|
width: min(1440px, calc(100% - 32px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 0 40px;
|
padding: 22px 0 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 16px;
|
||||||
z-index: 100;
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border-radius: 0 0 24px 24px;
|
border-radius: 24px;
|
||||||
background: linear-gradient(180deg, #0a1324 0%, #08101d 100%);
|
background: rgba(8, 14, 26, 0.82);
|
||||||
border: 1px solid rgba(133, 147, 169, 0.22);
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
border-top: none;
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
backdrop-filter: blur(16px);
|
||||||
overflow: hidden;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.panel) {
|
:global(.panel) {
|
||||||
@@ -139,14 +126,6 @@ const navItems = computed(() => [
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -154,8 +133,7 @@ const navItems = computed(() => [
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link,
|
.nav-link {
|
||||||
.locale-button {
|
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||||
@@ -168,8 +146,7 @@ const navItems = computed(() => [
|
|||||||
border-color 0.2s ease;
|
border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover,
|
.nav-link:hover {
|
||||||
.locale-button:hover {
|
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
background: rgba(25, 38, 66, 0.9);
|
background: rgba(25, 38, 66, 0.9);
|
||||||
}
|
}
|
||||||
@@ -181,24 +158,17 @@ const navItems = computed(() => [
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-button {
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-body {
|
.page-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
|
position: static;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions,
|
|
||||||
.nav {
|
.nav {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -209,7 +179,6 @@ const navItems = computed(() => [
|
|||||||
width: min(100%, calc(100% - 20px));
|
width: min(100%, calc(100% - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions,
|
|
||||||
.nav {
|
.nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -1,544 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { useControlInterface } from '@/composables/useControlInterface'
|
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
|
|
||||||
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: t('controlFeedback.forward'),
|
|
||||||
value: commandValues.value.lx,
|
|
||||||
max: controlLimits.value.forward,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('controlFeedback.strafe'),
|
|
||||||
value: commandValues.value.ly,
|
|
||||||
max: controlLimits.value.strafe,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('controlFeedback.turn'),
|
|
||||||
value: commandValues.value.az,
|
|
||||||
max: controlLimits.value.turn,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const tuningSummary = computed(() =>
|
|
||||||
t('controlFeedback.tuningSummary', {
|
|
||||||
forward: controlTuning.value.forward.toFixed(2),
|
|
||||||
strafe: controlTuning.value.strafe.toFixed(2),
|
|
||||||
turn: controlTuning.value.turn.toFixed(2),
|
|
||||||
turbo: controlTuning.value.turbo.toFixed(2),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const gamepadMeta = computed(() => {
|
|
||||||
if (!gamepadConnected.value) {
|
|
||||||
return t('controlFeedback.gamepadHint')
|
|
||||||
}
|
|
||||||
return t('controlFeedback.gamepadMeta', {
|
|
||||||
index: gamepadIndex.value ?? '--',
|
|
||||||
mapping: gamepadMapping.value || t('control.gamepad.unknownMapping'),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const outgoingCommandText = computed(() => t('controlFeedback.outgoingCommand', { command: commandLabel.value }))
|
|
||||||
|
|
||||||
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">
|
|
||||||
{{ t('controlFeedback.modeChip', { mode: controlInputModeLabel }) }}
|
|
||||||
</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">
|
|
||||||
{{ tuningSummary }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="feedback-grid" :class="{ compact }">
|
|
||||||
<section class="feedback-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<p class="label">{{ t('controlFeedback.keyboard') }}</p>
|
|
||||||
<strong>{{ pressedKeysLabel }}</strong>
|
|
||||||
</div>
|
|
||||||
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
|
|
||||||
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? t('common.turbo') : t('common.selected')) : t('common.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">{{ t('controlFeedback.gamepad') }}</p>
|
|
||||||
<strong>{{ gamepadConnected ? gamepadName : t('controlFeedback.waitingForController') }}</strong>
|
|
||||||
</div>
|
|
||||||
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
|
|
||||||
{{
|
|
||||||
gamepadConnected
|
|
||||||
? controlInputMode === 'gamepad'
|
|
||||||
? t('common.selected')
|
|
||||||
: t('common.standby')
|
|
||||||
: t('common.offline')
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="subtle">
|
|
||||||
{{ gamepadMeta }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="sticks">
|
|
||||||
<div class="stick-card">
|
|
||||||
<span>{{ t('controlFeedback.leftStick') }}</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>{{ t('controlFeedback.rightStick') }}</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">
|
|
||||||
{{ outgoingCommandText }}
|
|
||||||
</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>
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import ControlFeedback from '@/components/ControlFeedback.vue'
|
|
||||||
import { useControlInterface } from '@/composables/useControlInterface'
|
|
||||||
import { useLocale } from '@/lib/locale'
|
|
||||||
|
|
||||||
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
|
|
||||||
useControlInterface()
|
|
||||||
const { t } = useLocale()
|
|
||||||
|
|
||||||
const inputModes = computed(() => [
|
|
||||||
{ id: 'keyboard', label: t('common.keyboard'), detail: t('controlPanel.keyboardDetail') },
|
|
||||||
{ id: 'gamepad', label: t('common.gamepad'), detail: t('controlPanel.gamepadDetail') },
|
|
||||||
] 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">{{ t('controlPanel.eyebrow') }}</p>
|
|
||||||
<h2>{{ t('controlPanel.title') }}</h2>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="reset-button" @click="resetControlTuning">
|
|
||||||
{{ t('controlPanel.resetDefaults') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="mode-panel">
|
|
||||||
<div class="mode-panel-head">
|
|
||||||
<div>
|
|
||||||
<p class="mode-eyebrow">{{ t('controlPanel.inputModeEyebrow') }}</p>
|
|
||||||
<p class="mode-copy">{{ t('controlPanel.inputModeCopy') }}</p>
|
|
||||||
</div>
|
|
||||||
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mode-toggle" role="radiogroup" :aria-label="t('controlPanel.inputModeEyebrow')">
|
|
||||||
<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>{{ t('controlPanel.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>{{ t('controlPanel.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>{{ t('controlPanel.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>{{ t('controlPanel.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">
|
|
||||||
{{ t('controlPanel.keyboardHint') }}
|
|
||||||
</p>
|
|
||||||
<p class="hint subtle">
|
|
||||||
{{ t('controlPanel.tuningHint') }}
|
|
||||||
</p>
|
|
||||||
<p class="hint subtle">
|
|
||||||
{{ t('controlPanel.gamepadHint') }}
|
|
||||||
</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,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { formatDateTime, useLocale, type MessageKey } from '@/lib/locale'
|
|
||||||
import type { GpsTelemetry } from '@/types'
|
import type { GpsTelemetry } from '@/types'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -17,19 +16,12 @@ const props = defineProps<{
|
|||||||
gps: GpsTelemetry | null
|
gps: GpsTelemetry | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { locale, t } = useLocale()
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'robot_command_center_amap'
|
const STORAGE_KEY = 'robot_command_center_amap'
|
||||||
|
|
||||||
type StatusState = {
|
|
||||||
key: MessageKey
|
|
||||||
params?: Record<string, string | number | null | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyInput = ref('')
|
const keyInput = ref('')
|
||||||
const securityCodeInput = ref('')
|
const securityCodeInput = ref('')
|
||||||
const statusState = ref<StatusState>({ key: 'gpsMap.status.waitingInit' })
|
const statusText = ref('等待加载高德地图。')
|
||||||
const amapCoordinateRaw = ref('')
|
const amapCoordinateText = ref('暂无')
|
||||||
const mapElement = ref<HTMLDivElement | null>(null)
|
const mapElement = ref<HTMLDivElement | null>(null)
|
||||||
const mapRunning = ref(false)
|
const mapRunning = ref(false)
|
||||||
|
|
||||||
@@ -38,12 +30,6 @@ let mapInstance: any = null
|
|||||||
let marker: any = null
|
let marker: any = null
|
||||||
let infoWindow: any = null
|
let infoWindow: any = null
|
||||||
|
|
||||||
function setStatus(key: MessageKey, params?: Record<string, string | number | null | undefined>) {
|
|
||||||
statusState.value = { key, params }
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusText = computed(() => t(statusState.value.key, statusState.value.params))
|
|
||||||
|
|
||||||
function readSavedCredentials() {
|
function readSavedCredentials() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
@@ -67,10 +53,6 @@ function formatNumber(value: number) {
|
|||||||
return value.toFixed(6)
|
return value.toFixed(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHexText(value: string | null | undefined) {
|
|
||||||
return value || t('gpsMap.noValue')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAmapScript(key: string, securityJsCode: string) {
|
async function loadAmapScript(key: string, securityJsCode: string) {
|
||||||
if (window.AMap) {
|
if (window.AMap) {
|
||||||
return window.AMap
|
return window.AMap
|
||||||
@@ -87,7 +69,7 @@ async function loadAmapScript(key: string, securityJsCode: string) {
|
|||||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
||||||
script.async = true
|
script.async = true
|
||||||
script.onload = () => resolve(window.AMap)
|
script.onload = () => resolve(window.AMap)
|
||||||
script.onerror = () => reject(new Error(t('gpsMap.status.loadFailed')))
|
script.onerror = () => reject(new Error('高德地图脚本加载失败,请检查 Key / jscode 和网络。'))
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,7 +90,7 @@ function ensureMap() {
|
|||||||
|
|
||||||
marker = new window.AMap.Marker({
|
marker = new window.AMap.Marker({
|
||||||
anchor: 'bottom-center',
|
anchor: 'bottom-center',
|
||||||
title: t('gpsMap.infoTitle'),
|
title: 'Robot GPS',
|
||||||
})
|
})
|
||||||
|
|
||||||
infoWindow = new window.AMap.InfoWindow({
|
infoWindow = new window.AMap.InfoWindow({
|
||||||
@@ -118,7 +100,7 @@ function ensureMap() {
|
|||||||
|
|
||||||
function stopMap() {
|
function stopMap() {
|
||||||
mapRunning.value = false
|
mapRunning.value = false
|
||||||
amapCoordinateRaw.value = ''
|
amapCoordinateText.value = '已停止'
|
||||||
|
|
||||||
if (infoWindow) {
|
if (infoWindow) {
|
||||||
infoWindow.close()
|
infoWindow.close()
|
||||||
@@ -142,23 +124,7 @@ function stopMap() {
|
|||||||
mapElement.value.innerHTML = ''
|
mapElement.value.innerHTML = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('gpsMap.status.stopped')
|
statusText.value = '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。'
|
||||||
}
|
|
||||||
|
|
||||||
function buildInfoWindowContent(gps: GpsTelemetry, lat: number, lng: number) {
|
|
||||||
const altitudeText = gps.altitude_m == null ? t('common.unknown') : `${gps.altitude_m} m`
|
|
||||||
return [
|
|
||||||
'<div style="min-width: 240px; padding: 6px 2px; line-height: 1.75; font-size: 13px; color: #152033;">',
|
|
||||||
`<div style="margin-bottom: 8px; font-size: 14px; font-weight: 700; color: #0f172a;">${t('gpsMap.infoTitle')}</div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.wgs84')}:</span> <strong style="color: #0f172a;">${formatNumber(gps.latitude!)}, ${formatNumber(gps.longitude!)}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.gcj02')}:</span> <strong style="color: #0f172a;">${formatNumber(lat)}, ${formatNumber(lng)}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.rawLatHex')}:</span> <strong style="color: #0f172a;">${formatHexText(gps.raw_latitude_hex)}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.rawLonHex')}:</span> <strong style="color: #0f172a;">${formatHexText(gps.raw_longitude_hex)}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.utcTime')}:</span> <strong style="color: #0f172a;">${gps.utc_time || '--:--:--'}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.infoSatellites')}:</span> <strong style="color: #0f172a;">${gps.satellites ?? t('common.unknown')}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">${t('gpsMap.infoAltitude')}:</span> <strong style="color: #0f172a;">${altitudeText}</strong></div>`,
|
|
||||||
'</div>',
|
|
||||||
].join('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMap(gps: GpsTelemetry | null) {
|
function updateMap(gps: GpsTelemetry | null) {
|
||||||
@@ -167,10 +133,10 @@ function updateMap(gps: GpsTelemetry | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
|
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
|
||||||
amapCoordinateRaw.value = ''
|
amapCoordinateText.value = '暂无'
|
||||||
marker?.setMap(null)
|
marker?.setMap(null)
|
||||||
infoWindow?.close()
|
infoWindow?.close()
|
||||||
setStatus(gps ? 'gpsMap.status.noFix' : 'gpsMap.status.waitingGps')
|
statusText.value = gps ? 'GPS 在线,但当前还没有有效定位。' : '等待 GPS 数据。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +145,7 @@ function updateMap(gps: GpsTelemetry | null) {
|
|||||||
|
|
||||||
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
|
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
|
||||||
if (status !== 'complete' || !result?.locations?.length) {
|
if (status !== 'complete' || !result?.locations?.length) {
|
||||||
setStatus('gpsMap.status.convertFailed')
|
statusText.value = 'GPS 坐标转换失败。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,14 +153,25 @@ function updateMap(gps: GpsTelemetry | null) {
|
|||||||
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
|
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
|
||||||
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
|
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
|
||||||
|
|
||||||
amapCoordinateRaw.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
|
amapCoordinateText.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
|
||||||
marker.setPosition([lng, lat])
|
marker.setPosition([lng, lat])
|
||||||
marker.setMap(mapInstance)
|
marker.setMap(mapInstance)
|
||||||
|
|
||||||
infoWindow.setContent(buildInfoWindowContent(gps, lat, lng))
|
infoWindow.setContent(
|
||||||
|
[
|
||||||
|
'<div style="min-width: 240px; padding: 6px 2px; line-height: 1.75; font-size: 13px; color: #152033;">',
|
||||||
|
'<div style="margin-bottom: 8px; font-size: 14px; font-weight: 700; color: #0f172a;">Robot GPS 定位</div>',
|
||||||
|
`<div><span style="color: #667085;">原始 WGS84:</span> <strong style="color: #0f172a;">${formatNumber(rawLatitude)}, ${formatNumber(rawLongitude)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">高德 GCJ-02:</span> <strong style="color: #0f172a;">${formatNumber(lat)}, ${formatNumber(lng)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">UTC 时间:</span> <strong style="color: #0f172a;">${gps.utc_time || '--:--:--'}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">卫星数:</span> <strong style="color: #0f172a;">${gps.satellites ?? '未知'}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">海拔:</span> <strong style="color: #0f172a;">${gps.altitude_m ?? '未知'} m</strong></div>`,
|
||||||
|
'</div>',
|
||||||
|
].join(''),
|
||||||
|
)
|
||||||
infoWindow.open(mapInstance, [lng, lat])
|
infoWindow.open(mapInstance, [lng, lat])
|
||||||
mapInstance.setZoomAndCenter(17, [lng, lat])
|
mapInstance.setZoomAndCenter(17, [lng, lat])
|
||||||
setStatus('gpsMap.status.refreshedSource', { source: gps.source_mode })
|
statusText.value = `地图已刷新,数据源:${gps.source_mode}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,55 +180,47 @@ async function startMap() {
|
|||||||
const securityJsCode = securityCodeInput.value.trim()
|
const securityJsCode = securityCodeInput.value.trim()
|
||||||
|
|
||||||
if (!key || !securityJsCode) {
|
if (!key || !securityJsCode) {
|
||||||
setStatus('gpsMap.status.fillCredentials')
|
statusText.value = '请先填写高德 Key 和安全密钥 jscode。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('gpsMap.status.loading')
|
statusText.value = '正在加载高德地图...'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadAmapScript(key, securityJsCode)
|
await loadAmapScript(key, securityJsCode)
|
||||||
ensureMap()
|
ensureMap()
|
||||||
saveCredentials()
|
saveCredentials()
|
||||||
mapRunning.value = true
|
mapRunning.value = true
|
||||||
setStatus('gpsMap.status.loaded')
|
statusText.value = '地图已加载。'
|
||||||
updateMap(props.gps)
|
updateMap(props.gps)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('gpsMap.status.loadFailed')
|
statusText.value = error instanceof Error ? error.message : '地图加载失败。'
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
statusState.value = { key: 'gpsMap.status.loadFailed', params: { message: error.message } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawCoordinateText = computed(() => {
|
const rawCoordinateText = computed(() => {
|
||||||
if (!props.gps?.has_fix || props.gps.latitude == null || props.gps.longitude == null) {
|
if (!props.gps?.has_fix || props.gps.latitude == null || props.gps.longitude == null) {
|
||||||
return t('gpsMap.noValidFix')
|
return '暂无有效定位'
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${formatNumber(props.gps.latitude)}, ${formatNumber(props.gps.longitude)}`
|
return `${formatNumber(props.gps.latitude)}, ${formatNumber(props.gps.longitude)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const amapCoordinateText = computed(() => amapCoordinateRaw.value || t('gpsMap.noValue'))
|
|
||||||
const rawLatitudeHexText = computed(() => formatHexText(props.gps?.raw_latitude_hex))
|
|
||||||
const rawLongitudeHexText = computed(() => formatHexText(props.gps?.raw_longitude_hex))
|
|
||||||
|
|
||||||
const coordinateMetaText = computed(() => {
|
|
||||||
if (!props.gps) {
|
|
||||||
return t('gpsMap.noValue')
|
|
||||||
}
|
|
||||||
return `${props.gps.coordinate_system} / ${props.gps.raw_coordinate_format}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const metaText = computed(() => {
|
const metaText = computed(() => {
|
||||||
if (!props.gps) {
|
if (!props.gps) {
|
||||||
return t('gpsMap.noValue')
|
return '暂无'
|
||||||
}
|
}
|
||||||
const satellites = props.gps.satellites ?? t('common.unknown')
|
const satellites = props.gps.satellites ?? '未知'
|
||||||
const altitude = props.gps.altitude_m == null ? t('common.unknown') : `${props.gps.altitude_m} m`
|
const altitude = props.gps.altitude_m == null ? '未知' : `${props.gps.altitude_m} m`
|
||||||
return `${satellites} / ${altitude}`
|
return `${satellites} 颗 / ${altitude}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const updatedAtText = computed(() => formatDateTime(props.gps?.updated_at))
|
const updatedAtText = computed(() => {
|
||||||
|
if (!props.gps?.updated_at) {
|
||||||
|
return '暂无'
|
||||||
|
}
|
||||||
|
return new Date(props.gps.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const saved = readSavedCredentials()
|
const saved = readSavedCredentials()
|
||||||
@@ -259,7 +228,7 @@ onMounted(() => {
|
|||||||
keyInput.value = saved.key ?? ''
|
keyInput.value = saved.key ?? ''
|
||||||
securityCodeInput.value = saved.securityJsCode ?? ''
|
securityCodeInput.value = saved.securityJsCode ?? ''
|
||||||
if (keyInput.value && securityCodeInput.value) {
|
if (keyInput.value && securityCodeInput.value) {
|
||||||
setStatus('gpsMap.status.restoredConfig')
|
statusText.value = '已恢复高德配置。高德地图不会自动加载,请按需点击“加载地图”。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -271,76 +240,62 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => locale.value,
|
|
||||||
() => {
|
|
||||||
if (mapRunning.value) {
|
|
||||||
updateMap(props.gps)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel map-panel">
|
<section class="panel map-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('gpsMap.eyebrow') }}</p>
|
<p class="eyebrow">GPS</p>
|
||||||
<h2>{{ t('gpsMap.title') }}</h2>
|
<h2>地图定位</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ gps?.source_mode ?? t('common.loading') }}</span>
|
<span class="badge">{{ gps?.source_mode ?? 'loading' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="intro">{{ t('gpsMap.intro') }}</p>
|
<p class="intro">
|
||||||
|
这里复用了你原来 `GeoStream/gps_map.html` 的高德地图思路。后端优先读取
|
||||||
|
`GeoStream/gps_latest.json`,所以你运行 `parse_gps.c` 生成数据后,这里会直接接上。
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="credentials">
|
<div class="credentials">
|
||||||
<input v-model="keyInput" type="text" :placeholder="t('gpsMap.keyPlaceholder')" />
|
<input v-model="keyInput" type="text" placeholder="高德 Web 端 Key" />
|
||||||
<input v-model="securityCodeInput" type="text" :placeholder="t('gpsMap.jscodePlaceholder')" />
|
<input v-model="securityCodeInput" type="text" placeholder="安全密钥 jscode" />
|
||||||
<button type="button" @click="startMap">{{ t('gpsMap.loadMap') }}</button>
|
<button type="button" @click="startMap">加载地图</button>
|
||||||
<button type="button" class="secondary" @click="stopMap">{{ t('gpsMap.stopMap') }}</button>
|
<button type="button" class="secondary" @click="stopMap">停止加载</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status">{{ statusText }}</div>
|
<div class="status">{{ statusText }}</div>
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>{{ t('gpsMap.wgs84') }}</span>
|
<span>原始 WGS84</span>
|
||||||
<strong>{{ rawCoordinateText }}</strong>
|
<strong>{{ rawCoordinateText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>{{ t('gpsMap.gcj02') }}</span>
|
<span>高德 GCJ-02</span>
|
||||||
<strong>{{ amapCoordinateText }}</strong>
|
<strong>{{ amapCoordinateText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>{{ t('gpsMap.rawLatHex') }}</span>
|
<span>UTC 时间</span>
|
||||||
<strong class="mono">{{ rawLatitudeHexText }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="detail-card">
|
|
||||||
<span>{{ t('gpsMap.rawLonHex') }}</span>
|
|
||||||
<strong class="mono">{{ rawLongitudeHexText }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="detail-card">
|
|
||||||
<span>{{ t('gpsMap.utcTime') }}</span>
|
|
||||||
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
|
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>{{ t('gpsMap.satAltitude') }}</span>
|
<span>卫星 / 海拔</span>
|
||||||
<strong>{{ metaText }}</strong>
|
<strong>{{ metaText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>{{ t('gpsMap.coordMeta') }}</span>
|
<span>坐标系</span>
|
||||||
<strong>{{ coordinateMetaText }}</strong>
|
<strong>{{ gps?.coordinate_system ?? 'WGS84' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>{{ t('gpsMap.lastUpdated') }}</span>
|
<span>最近刷新</span>
|
||||||
<strong>{{ updatedAtText }}</strong>
|
<strong>{{ updatedAtText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
|
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
|
||||||
<div v-if="!mapRunning" class="map-placeholder">
|
<div v-if="!mapRunning" class="map-placeholder">
|
||||||
{{ t('gpsMap.mapPlaceholder') }}
|
高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -442,11 +397,6 @@ h2 {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card strong.mono {
|
|
||||||
font-family: 'JetBrains Mono', 'SFMono-Regular', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-canvas {
|
.map-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
|
|||||||
@@ -1,241 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { formatDateTime, t } from '@/lib/locale'
|
import type { NetworkTelemetry } from '@/types'
|
||||||
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
network: NetworkTelemetry | null
|
network: NetworkTelemetry | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const legCards = computed(() => [
|
const updatedAt = computed(() => {
|
||||||
{
|
if (!props.network?.updated_at) {
|
||||||
key: 'a_to_d',
|
return '暂无'
|
||||||
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(() => formatControlSource(props.network?.active_control_source))
|
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
|
||||||
return formatDateTime(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatScalar(value?: number | string | null, suffix = '') {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return '--'
|
|
||||||
}
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return `${value.toFixed(1)}${suffix}`
|
|
||||||
}
|
|
||||||
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 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatControlSource(source?: string | null) {
|
|
||||||
if (source === 'keyboard') return t('common.keyboard')
|
|
||||||
if (source === 'gamepad') return t('common.gamepad')
|
|
||||||
return t('common.none')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBoolean(value?: boolean | number | null) {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return t('common.na')
|
|
||||||
}
|
|
||||||
return value ? t('common.yes') : t('common.no')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAckMode(ackAvailable?: boolean) {
|
|
||||||
return ackAvailable ? t('common.ackLoop') : t('common.srttFallback')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStale(stale?: boolean | null) {
|
|
||||||
if (stale == null) {
|
|
||||||
return t('common.na')
|
|
||||||
}
|
|
||||||
return stale ? t('common.stale') : t('common.fresh')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatOnline(online?: boolean | null) {
|
|
||||||
if (online == null) {
|
|
||||||
return t('common.na')
|
|
||||||
}
|
|
||||||
return online ? t('common.online') : t('common.idle')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSessionName(name: string) {
|
|
||||||
return name === 'control' ? t('common.control') : t('common.video')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTrend(value?: string | null) {
|
|
||||||
if (value === 'rising') return t('common.rising')
|
|
||||||
if (value === 'falling') return t('common.falling')
|
|
||||||
return t('common.stable')
|
|
||||||
}
|
}
|
||||||
|
return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel network-panel">
|
<section class="panel network-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('networkPanel.eyebrow') }}</p>
|
<p class="eyebrow">Network</p>
|
||||||
<h2>{{ t('networkPanel.title') }}</h2>
|
<h2>链路状态</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="{ stale: network?.telemetry_receiver?.hub_stale }">
|
<span class="badge">{{ network?.peer_status ?? 'loading' }}</span>
|
||||||
{{ network?.peer_status ?? t('networkPanel.loadingPeer') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('networkPanel.controlLoopRtt') }}</span>
|
<span>延迟</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.control_loop_rtt_ms, ' ms') }}</strong>
|
<strong>{{ network?.latency_ms ?? '--' }} ms</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('networkPanel.controlToPersist') }}</span>
|
<span>抖动</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.control_to_persist_est_ms, ' ms') }}</strong>
|
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('networkPanel.controlSrttOneWay') }}</span>
|
<span>Retrans</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.control_oneway_srtt_est_ms, ' ms') }}</strong>
|
<strong>{{ network?.retrans_pct ?? network?.packet_loss_pct ?? '--' }} %</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('networkPanel.videoOneWayEst') }}</span>
|
<span>信号强度</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.video_network_oneway_est_ms, ' ms') }}</strong>
|
<strong>{{ network?.signal_dbm ?? '--' }} dBm</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('networkPanel.txRate') }}</span>
|
<span>发送速率</span>
|
||||||
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
|
<strong>{{ network?.tx_kbps ?? '--' }} kbps</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('networkPanel.rxRate') }}</span>
|
<span>接收速率</span>
|
||||||
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
|
<strong>{{ network?.rx_kbps ?? '--' }} kbps</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary telemetry-strip">
|
|
||||||
<p><strong>{{ t('networkPanel.robotFault') }}:</strong> {{ network?.robot_health?.fault_reason ?? t('common.na') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.recoveryState') }}:</strong> {{ network?.robot_health?.recovery_state ?? t('common.na') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.healthConfidence') }}:</strong> {{ network?.robot_health?.confidence ?? t('common.na') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.healthUpdated') }}:</strong> {{ formatTime(network?.robot_health?.updated_at) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.transport') }}:</strong> {{ network?.transport ?? t('common.na') }} / {{ network?.source_mode ?? t('common.na') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.activeControl') }}:</strong> {{ activeSource }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.lease') }}:</strong> {{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.ackMode') }}:</strong> {{ formatAckMode(network?.control_ack_status?.ack_available) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.ackUpdated') }}:</strong> {{ formatTime(network?.control_ack_status?.updated_at) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.telemetryPeer') }}:</strong> {{ network?.telemetry_receiver?.peer_id ?? t('common.na') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.telemetryRegistered') }}:</strong> {{ formatBoolean(network?.telemetry_receiver?.registered) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.hubFreshness') }}:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.hubState') }}:</strong> {{ formatStale(network?.telemetry_receiver?.hub_stale) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.telemetryReconnects') }}:</strong> {{ network?.telemetry_receiver?.reconnect_count ?? 0 }}</p>
|
|
||||||
<p v-if="network?.telemetry_receiver?.last_error"><strong>{{ t('networkPanel.hubError') }}:</strong> {{ network?.telemetry_receiver?.last_error }}</p>
|
|
||||||
<p v-if="network?.telemetry_receiver?.last_server_error"><strong>{{ t('networkPanel.telemetrySessionError') }}:</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 ?? t('common.waiting') }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="leg-meta">
|
|
||||||
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
|
|
||||||
{{ formatStale(leg.data?.stale) }}
|
|
||||||
</span>
|
|
||||||
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aggregate-grid">
|
|
||||||
<div>
|
|
||||||
<span>{{ t('networkPanel.online') }}</span>
|
|
||||||
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{{ t('networkPanel.maxPressure') }}</span>
|
|
||||||
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{{ t('networkPanel.queued') }}</span>
|
|
||||||
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{{ t('networkPanel.inFlightBuffer') }}</span>
|
|
||||||
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{{ t('networkPanel.retransDelta') }}</span>
|
|
||||||
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{{ t('networkPanel.repairRate') }}</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">{{ formatSessionName(session.name) }}</p>
|
|
||||||
<h4>{{ session.data?.peer_id ?? t('networkPanel.unassigned') }}</h4>
|
|
||||||
</div>
|
|
||||||
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
|
|
||||||
{{ formatOnline(session.data?.connected) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv-grid">
|
|
||||||
<p><strong>{{ t('networkPanel.updated') }}:</strong> {{ formatTime(session.data?.updated_at) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.srtt') }}:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.rttvar') }}:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.rto') }}:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.sndWnd') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.rmtWnd') }}:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.inflight') }}:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.windowLimit') }}:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.pressure') }}:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.sndQueue') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ formatTrend(session.data?.trend?.snd_queue_trend) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.sndBuffer') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ formatTrend(session.data?.trend?.snd_buffer_trend) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.queueDelta') }}:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.bufferDelta') }}:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.retrans') }}:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.fastRetrans') }}:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.lost') }}:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.repeat') }}:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.repairRate') }}:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</p>
|
|
||||||
<p v-if="session.data?.app"><strong>{{ t('networkPanel.appBytes') }}:</strong> tx={{ session.data.app.send_bytes ?? 0 }} / rx={{ session.data.app.recv_bytes ?? 0 }}</p>
|
|
||||||
<p v-if="session.data?.app"><strong>{{ t('networkPanel.registered') }}:</strong> {{ formatBoolean(session.data.app.registered) }}</p>
|
|
||||||
<p v-if="session.data?.app?.last_server_error"><strong>{{ t('networkPanel.serverError') }}:</strong> {{ session.data.app.last_server_error }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<p><strong>{{ t('networkPanel.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>来源:</strong>{{ network?.transport ?? '暂无' }} / {{ network?.source_mode ?? '暂无' }}</p>
|
||||||
<p><strong>{{ t('networkPanel.videoE2E') }}:</strong> {{ formatScalar(network?.latency_estimate?.video_e2e_est_ms, ' ms') }} / confidence={{ network?.latency_estimate?.confidence?.video ?? t('common.na') }}</p>
|
<p><strong>刷新:</strong>{{ updatedAt }}</p>
|
||||||
<p><strong>{{ t('networkPanel.controlEstimateConfidence') }}:</strong> {{ network?.latency_estimate?.confidence?.control ?? t('common.na') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.videoFreshness') }}:</strong> {{ t('networkPanel.videoFreshnessRepeat') }}={{ formatScalar((network?.video_freshness?.repeated_frame_ratio ?? 0) * 100, '%') }} {{ t('networkPanel.videoFreshnessSkip') }}={{ formatScalar((network?.video_freshness?.skip_ratio ?? 0) * 100, '%') }} {{ t('networkPanel.videoFreshnessFreeze') }}={{ formatScalar(network?.video_freshness?.longest_freeze_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.nativeUdp') }}:</strong> {{ network?.ingress?.native_udp?.bind_addr ?? t('common.na') }} packets={{ network?.ingress?.native_udp?.packets_received ?? 0 }} invalid={{ network?.ingress?.native_udp?.invalid_packets ?? 0 }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.controlSender') }}:</strong> {{ network?.control?.sender?.peer_id ?? t('common.na') }} -> {{ network?.control?.sender?.target_peer ?? t('common.na') }} sends={{ network?.control?.sender?.send_count ?? 0 }} registered={{ formatBoolean(network?.control?.sender?.registered) }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.ackReceiver') }}:</strong> {{ network?.control?.ack_receiver?.peer_id ?? t('common.na') }} reconnects={{ network?.control?.ack_receiver?.reconnect_count ?? 0 }}</p>
|
|
||||||
<p><strong>{{ t('networkPanel.controlReconnects') }}:</strong> {{ network?.control?.sender?.reconnect_count ?? 0 }}</p>
|
|
||||||
<p v-if="network?.control?.sender?.last_server_error"><strong>{{ t('networkPanel.controlSessionError') }}:</strong> {{ network?.control?.sender?.last_server_error }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -243,134 +62,73 @@ function formatTrend(value?: string | null) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.network-panel {
|
.network-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-head,
|
.panel-head {
|
||||||
.leg-head,
|
|
||||||
.session-head {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow,
|
.eyebrow {
|
||||||
.leg-label,
|
|
||||||
.session-label {
|
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
color: #5bd3b5;
|
color: #4dd4ac;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.12em;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge,
|
|
||||||
.mini-badge {
|
|
||||||
border-radius: 999px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
background: rgba(40, 199, 111, 0.16);
|
background: rgba(40, 199, 111, 0.16);
|
||||||
color: #63e6a9;
|
color: #63e6a9;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
.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;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-grid {
|
.stat-card {
|
||||||
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;
|
padding: 14px;
|
||||||
border-radius: 18px;
|
border-radius: 16px;
|
||||||
background: rgba(7, 14, 26, 0.8);
|
background: rgba(7, 14, 26, 0.78);
|
||||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
color: #d5dbee;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card span,
|
.stat-card span {
|
||||||
.aggregate-grid span {
|
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: #8d99b3;
|
color: #8d99b3;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card strong,
|
.stat-card strong {
|
||||||
.aggregate-grid strong {
|
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary p,
|
.summary {
|
||||||
.kv-grid p {
|
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 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,57 +136,14 @@ h4 {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-strip {
|
@media (max-width: 960px) {
|
||||||
display: grid;
|
.stats {
|
||||||
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));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-grid,
|
|
||||||
.session-grid,
|
|
||||||
.kv-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 640px) {
|
||||||
.stats,
|
.stats {
|
||||||
.aggregate-grid,
|
|
||||||
.telemetry-strip,
|
|
||||||
.kv-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,448 +1,64 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { buildVideoFrameUrl, fetchClockCalibrationSample, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
|
import { buildVideoStreamUrl } from '@/lib/api'
|
||||||
import { t } from '@/lib/locale'
|
import type { VideoStatus } from '@/types'
|
||||||
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
|
|
||||||
import type { NetworkTelemetry, VideoStatus } from '@/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
video: VideoStatus | null
|
video: VideoStatus | null
|
||||||
network?: NetworkTelemetry | null
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const STATUS_REFRESH_MS = 300
|
const streamUrl = ref('')
|
||||||
const DISPLAY_PROBE_INTERVAL_MS = 200
|
const canRequestFrames = computed(() => props.video?.available === true)
|
||||||
const CLOCK_CALIBRATION_INTERVAL_MS = 1000
|
const streamFps = computed(() => Math.max(props.video?.fps ?? 0, 30))
|
||||||
const CLOCK_CALIBRATION_SAMPLE_WINDOW = 9
|
|
||||||
const CLOCK_CALIBRATION_STALE_MS = CLOCK_CALIBRATION_INTERVAL_MS * 3
|
|
||||||
|
|
||||||
type PendingInputProbe = {
|
const senderProfileLabel = computed(() => {
|
||||||
token: number
|
if (!props.video) {
|
||||||
triggeredPerfMs: number
|
return '发送端 Profile: --'
|
||||||
baselineFrameSeq: number | null
|
|
||||||
baselineFrameHash: string
|
|
||||||
freshResolved: boolean
|
|
||||||
changedResolved: boolean
|
|
||||||
paintResolved: boolean
|
|
||||||
}
|
}
|
||||||
|
return `发送端 Profile: ${props.video.fps} FPS`
|
||||||
type ClockCalibrationSnapshot = {
|
|
||||||
offsetMs: number | null
|
|
||||||
rttMs: number | null
|
|
||||||
sampleCount: number
|
|
||||||
updatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const liveVideo = ref<VideoStatus | null>(props.video)
|
|
||||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
|
||||||
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
|
||||||
const canRequestFrames = computed(() => displayVideo.value?.available === true)
|
|
||||||
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
|
|
||||||
const operatorMetrics = ref({
|
|
||||||
input_to_next_fresh_frame_ms: null as number | null,
|
|
||||||
input_to_next_changed_frame_ms: null as number | null,
|
|
||||||
input_to_next_paint_ms: null as number | null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const displayModeLabel = computed(() => {
|
||||||
operatorInputSequence,
|
if (!props.video) {
|
||||||
lastOperatorInputPerfMs,
|
return '前端显示: 等待状态'
|
||||||
} = useOperatorInputTelemetry()
|
|
||||||
|
|
||||||
const freshness = computed(() => displayVideo.value?.freshness)
|
|
||||||
const networkEstimate = computed(() => props.network?.latency_estimate ?? null)
|
|
||||||
const senderClockDebug = computed(() => displayVideo.value?.timing ?? null)
|
|
||||||
const EMPTY_CLOCK_CALIBRATION: ClockCalibrationSnapshot = {
|
|
||||||
offsetMs: null,
|
|
||||||
rttMs: null,
|
|
||||||
sampleCount: 0,
|
|
||||||
updatedAt: null,
|
|
||||||
}
|
|
||||||
const clockCalibration = ref<ClockCalibrationSnapshot>({ ...EMPTY_CLOCK_CALIBRATION })
|
|
||||||
|
|
||||||
const modeLabel = computed(() => {
|
|
||||||
if (!displayVideo.value) {
|
|
||||||
return t('videoPanel.mode.loading')
|
|
||||||
}
|
|
||||||
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
|
|
||||||
return t('videoPanel.mode.live', { fps: displayVideo.value.fps })
|
|
||||||
}
|
|
||||||
return displayVideo.value.source_mode
|
|
||||||
})
|
|
||||||
|
|
||||||
const timingHeadline = computed(() => {
|
|
||||||
const latest = senderClockDebug.value?.sender_clock_delta_ms_raw
|
|
||||||
if (latest == null) {
|
|
||||||
return t('videoPanel.timing.waiting')
|
|
||||||
}
|
|
||||||
return `${latest.toFixed(1)} ms`
|
|
||||||
})
|
|
||||||
|
|
||||||
const timingHint = computed(() => {
|
|
||||||
const timing = senderClockDebug.value
|
|
||||||
if (!timing?.available) {
|
|
||||||
return t('videoPanel.timing.noTrailer')
|
|
||||||
}
|
|
||||||
return t('videoPanel.timing.rawHint')
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, suffix = '') {
|
|
||||||
if (value == null || Number.isNaN(value)) {
|
|
||||||
return '--'
|
|
||||||
}
|
|
||||||
return `${value.toFixed(1)}${suffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function wallClockNowMs() {
|
|
||||||
return Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
let frameTimer: number | null = null
|
|
||||||
let statusTimer: number | null = null
|
|
||||||
let probeTimer: number | null = null
|
|
||||||
let calibrationTimer: number | null = null
|
|
||||||
let frameKey = 0
|
|
||||||
let probeKey = 0
|
|
||||||
let statusRequestPending = false
|
|
||||||
let probeRequestPending = false
|
|
||||||
let calibrationRequestPending = false
|
|
||||||
let lastObservedFrameSeq: number | null = null
|
|
||||||
let lastObservedFrameHash = ''
|
|
||||||
let pendingInputProbe: PendingInputProbe | null = null
|
|
||||||
let clockOffsetSamples: number[] = []
|
|
||||||
let clockRttSamples: number[] = []
|
|
||||||
|
|
||||||
function boundedMedian(samples: number[]) {
|
|
||||||
if (samples.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const sorted = [...samples].sort((left, right) => left - right)
|
|
||||||
const middle = Math.floor(sorted.length / 2)
|
|
||||||
if (sorted.length % 2 === 1) {
|
|
||||||
return sorted[middle] ?? null
|
|
||||||
}
|
|
||||||
const left = sorted[middle - 1]
|
|
||||||
const right = sorted[middle]
|
|
||||||
if (left == null || right == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (left + right) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearClockCalibration() {
|
|
||||||
clockOffsetSamples = []
|
|
||||||
clockRttSamples = []
|
|
||||||
clockCalibration.value = { ...EMPTY_CLOCK_CALIBRATION }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClockCalibrationFresh(snapshot: ClockCalibrationSnapshot, nowMs = wallClockNowMs()) {
|
|
||||||
if (snapshot.offsetMs == null || snapshot.rttMs == null || !snapshot.updatedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const updatedAtMs = Date.parse(snapshot.updatedAt)
|
|
||||||
if (!Number.isFinite(updatedAtMs)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return nowMs - updatedAtMs <= CLOCK_CALIBRATION_STALE_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
function expireClockCalibrationIfStale(nowMs = wallClockNowMs()) {
|
|
||||||
if (clockCalibration.value.sampleCount > 0 && !isClockCalibrationFresh(clockCalibration.value, nowMs)) {
|
|
||||||
clearClockCalibration()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentClockCalibration(nowMs = wallClockNowMs()) {
|
|
||||||
expireClockCalibrationIfStale(nowMs)
|
|
||||||
return clockCalibration.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateClockCalibration(offsetMs: number, rttMs: number) {
|
|
||||||
clockOffsetSamples.push(offsetMs)
|
|
||||||
clockRttSamples.push(rttMs)
|
|
||||||
if (clockOffsetSamples.length > CLOCK_CALIBRATION_SAMPLE_WINDOW) {
|
|
||||||
clockOffsetSamples = clockOffsetSamples.slice(-CLOCK_CALIBRATION_SAMPLE_WINDOW)
|
|
||||||
}
|
|
||||||
if (clockRttSamples.length > CLOCK_CALIBRATION_SAMPLE_WINDOW) {
|
|
||||||
clockRttSamples = clockRttSamples.slice(-CLOCK_CALIBRATION_SAMPLE_WINDOW)
|
|
||||||
}
|
|
||||||
const medianOffset = boundedMedian(clockOffsetSamples)
|
|
||||||
const medianRtt = boundedMedian(clockRttSamples)
|
|
||||||
clockCalibration.value = {
|
|
||||||
offsetMs: medianOffset == null ? null : Number(medianOffset.toFixed(3)),
|
|
||||||
rttMs: medianRtt == null ? null : Number(medianRtt.toFixed(3)),
|
|
||||||
sampleCount: clockOffsetSamples.length,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshStatus() {
|
|
||||||
if (statusRequestPending) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statusRequestPending = true
|
|
||||||
try {
|
|
||||||
liveVideo.value = await fetchVideoStatus()
|
|
||||||
} catch {
|
|
||||||
// Keep the last good state.
|
|
||||||
} finally {
|
|
||||||
statusRequestPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runClockCalibration() {
|
|
||||||
if (calibrationRequestPending) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
calibrationRequestPending = true
|
|
||||||
const clientSendUnixMs = wallClockNowMs()
|
|
||||||
expireClockCalibrationIfStale(clientSendUnixMs)
|
|
||||||
try {
|
|
||||||
const sample = await fetchClockCalibrationSample()
|
|
||||||
const clientRecvUnixMs = wallClockNowMs()
|
|
||||||
if (!Number.isFinite(sample.server_received_unix_ms) || !Number.isFinite(sample.server_sent_unix_ms)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const offsetMs = ((clientSendUnixMs - sample.server_received_unix_ms) + (clientRecvUnixMs - sample.server_sent_unix_ms)) / 2
|
|
||||||
const rawRttMs = (clientRecvUnixMs - clientSendUnixMs) - (sample.server_sent_unix_ms - sample.server_received_unix_ms)
|
|
||||||
updateClockCalibration(Number(offsetMs.toFixed(3)), Number(Math.max(0, rawRttMs).toFixed(3)))
|
|
||||||
} catch {
|
|
||||||
// Calibration is best-effort only.
|
|
||||||
} finally {
|
|
||||||
expireClockCalibrationIfStale()
|
|
||||||
calibrationRequestPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshFrame() {
|
|
||||||
if (!canRequestFrames.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
frameKey += 1
|
|
||||||
frameUrl.value = buildVideoFrameUrl(frameKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startFrameLoop() {
|
|
||||||
if (frameTimer != null) {
|
|
||||||
window.clearInterval(frameTimer)
|
|
||||||
frameTimer = null
|
|
||||||
}
|
}
|
||||||
if (!canRequestFrames.value) {
|
if (!canRequestFrames.value) {
|
||||||
return
|
return '前端显示: 等待新视频帧'
|
||||||
}
|
}
|
||||||
refreshFrame()
|
return `前端显示: MJPEG Stream (${streamFps.value} FPS 请求)`
|
||||||
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
|
|
||||||
frameTimer = window.setInterval(refreshFrame, intervalMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startStatusLoop() {
|
|
||||||
if (statusTimer != null) {
|
|
||||||
window.clearInterval(statusTimer)
|
|
||||||
statusTimer = null
|
|
||||||
}
|
|
||||||
void refreshStatus()
|
|
||||||
statusTimer = window.setInterval(() => {
|
|
||||||
void refreshStatus()
|
|
||||||
}, STATUS_REFRESH_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startClockCalibrationLoop() {
|
|
||||||
if (calibrationTimer != null) {
|
|
||||||
window.clearInterval(calibrationTimer)
|
|
||||||
calibrationTimer = null
|
|
||||||
}
|
|
||||||
void runClockCalibration()
|
|
||||||
calibrationTimer = window.setInterval(() => {
|
|
||||||
void runClockCalibration()
|
|
||||||
}, CLOCK_CALIBRATION_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeTrackOperatorInput() {
|
|
||||||
pendingInputProbe = {
|
|
||||||
token: operatorInputSequence.value,
|
|
||||||
triggeredPerfMs: lastOperatorInputPerfMs.value,
|
|
||||||
baselineFrameSeq: lastObservedFrameSeq,
|
|
||||||
baselineFrameHash: lastObservedFrameHash,
|
|
||||||
freshResolved: false,
|
|
||||||
changedResolved: false,
|
|
||||||
paintResolved: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDisplayProbe() {
|
|
||||||
if (probeRequestPending || !canRequestFrames.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
probeRequestPending = true
|
|
||||||
const requestStartedUnixMs = wallClockNowMs()
|
|
||||||
|
|
||||||
try {
|
|
||||||
probeKey += 1
|
|
||||||
const response = await fetch(buildVideoFrameUrl(probeKey), {
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
|
||||||
return
|
const placeholderText = computed(() => {
|
||||||
|
if (!props.video) {
|
||||||
|
return '正在获取视频状态...'
|
||||||
}
|
}
|
||||||
|
return '尚未收到实时视频帧'
|
||||||
const frameSeqHeader = response.headers.get('X-Blitz-Frame-Seq')
|
|
||||||
const backendReceivedHeader = response.headers.get('X-Blitz-Backend-Received-Unix-Ns')
|
|
||||||
const frameHashHeader = response.headers.get('X-Blitz-Frame-Hash') ?? ''
|
|
||||||
const frameSeq = frameSeqHeader ? Number(frameSeqHeader) : null
|
|
||||||
const backendReceivedUnixNs = backendReceivedHeader ? Number(backendReceivedHeader) : null
|
|
||||||
const responseReceivedUnixMs = wallClockNowMs()
|
|
||||||
const blob = await response.blob()
|
|
||||||
const objectUrl = URL.createObjectURL(blob)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const probeImage = new Image()
|
|
||||||
probeImage.src = objectUrl
|
|
||||||
await probeImage.decode()
|
|
||||||
const decodedUnixMs = wallClockNowMs()
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
requestAnimationFrame(() => resolve())
|
|
||||||
})
|
})
|
||||||
const paintUnixMs = wallClockNowMs()
|
|
||||||
|
|
||||||
let inputToNextFreshFrameMs: number | null = null
|
function refreshStreamUrl() {
|
||||||
let inputToNextChangedFrameMs: number | null = null
|
|
||||||
let inputToNextPaintMs: number | null = null
|
|
||||||
|
|
||||||
if (pendingInputProbe != null) {
|
|
||||||
if (
|
|
||||||
!pendingInputProbe.freshResolved &&
|
|
||||||
frameSeq != null &&
|
|
||||||
(pendingInputProbe.baselineFrameSeq == null || frameSeq > pendingInputProbe.baselineFrameSeq)
|
|
||||||
) {
|
|
||||||
inputToNextFreshFrameMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
|
|
||||||
pendingInputProbe.freshResolved = true
|
|
||||||
operatorMetrics.value.input_to_next_fresh_frame_ms = inputToNextFreshFrameMs
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!pendingInputProbe.changedResolved &&
|
|
||||||
frameHashHeader &&
|
|
||||||
frameHashHeader !== pendingInputProbe.baselineFrameHash
|
|
||||||
) {
|
|
||||||
inputToNextChangedFrameMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
|
|
||||||
pendingInputProbe.changedResolved = true
|
|
||||||
operatorMetrics.value.input_to_next_changed_frame_ms = inputToNextChangedFrameMs
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pendingInputProbe.paintResolved) {
|
|
||||||
inputToNextPaintMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
|
|
||||||
pendingInputProbe.paintResolved = true
|
|
||||||
operatorMetrics.value.input_to_next_paint_ms = inputToNextPaintMs
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
pendingInputProbe.freshResolved &&
|
|
||||||
pendingInputProbe.changedResolved &&
|
|
||||||
pendingInputProbe.paintResolved
|
|
||||||
) {
|
|
||||||
pendingInputProbe = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastObservedFrameSeq = frameSeq
|
|
||||||
lastObservedFrameHash = frameHashHeader
|
|
||||||
const calibration = currentClockCalibration(paintUnixMs)
|
|
||||||
|
|
||||||
await postVideoDisplayProbe({
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
frame_seq: frameSeq,
|
|
||||||
backend_received_unix_ns: backendReceivedUnixNs,
|
|
||||||
frame_hash: frameHashHeader,
|
|
||||||
request_started_unix_ms: Number(requestStartedUnixMs.toFixed(3)),
|
|
||||||
response_received_unix_ms: Number(responseReceivedUnixMs.toFixed(3)),
|
|
||||||
image_decoded_unix_ms: Number(decodedUnixMs.toFixed(3)),
|
|
||||||
paint_unix_ms: Number(paintUnixMs.toFixed(3)),
|
|
||||||
input_to_next_fresh_frame_ms: inputToNextFreshFrameMs,
|
|
||||||
input_to_next_changed_frame_ms: inputToNextChangedFrameMs,
|
|
||||||
input_to_next_paint_ms: inputToNextPaintMs,
|
|
||||||
browser_backend_clock_offset_ms: calibration.offsetMs,
|
|
||||||
browser_backend_clock_rtt_ms: calibration.rttMs,
|
|
||||||
browser_backend_clock_sample_count: calibration.sampleCount,
|
|
||||||
browser_backend_clock_calibrated_at: calibration.updatedAt,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(objectUrl)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Probe is best-effort only.
|
|
||||||
} finally {
|
|
||||||
probeRequestPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startProbeLoop() {
|
|
||||||
if (probeTimer != null) {
|
|
||||||
window.clearInterval(probeTimer)
|
|
||||||
probeTimer = null
|
|
||||||
}
|
|
||||||
if (!canRequestFrames.value) {
|
if (!canRequestFrames.value) {
|
||||||
|
streamUrl.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void runDisplayProbe()
|
|
||||||
probeTimer = window.setInterval(() => {
|
// Keep the browser attached to a higher-frequency local MJPEG stream
|
||||||
void runDisplayProbe()
|
// so the dashboard does not add an extra 0-100 ms polling delay.
|
||||||
}, DISPLAY_PROBE_INTERVAL_MS)
|
streamUrl.value = buildVideoStreamUrl(streamFps.value, Date.now())
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch([streamFps, canRequestFrames], refreshStreamUrl, { immediate: true })
|
||||||
startStatusLoop()
|
|
||||||
startFrameLoop()
|
|
||||||
startProbeLoop()
|
|
||||||
startClockCalibrationLoop()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (frameTimer != null) {
|
|
||||||
window.clearInterval(frameTimer)
|
|
||||||
}
|
|
||||||
if (statusTimer != null) {
|
|
||||||
window.clearInterval(statusTimer)
|
|
||||||
}
|
|
||||||
if (probeTimer != null) {
|
|
||||||
window.clearInterval(probeTimer)
|
|
||||||
}
|
|
||||||
if (calibrationTimer != null) {
|
|
||||||
window.clearInterval(calibrationTimer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.video,
|
|
||||||
(nextVideo) => {
|
|
||||||
liveVideo.value = nextVideo
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch([currentFps, canRequestFrames], () => {
|
|
||||||
startFrameLoop()
|
|
||||||
startProbeLoop()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => operatorInputSequence.value,
|
|
||||||
() => {
|
|
||||||
maybeTrackOperatorInput()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel video-panel">
|
<section class="panel video-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('videoPanel.eyebrow') }}</p>
|
<p class="eyebrow">Video</p>
|
||||||
<h2>{{ t('videoPanel.title') }}</h2>
|
<h2>JPEG 视频流</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="{ bad: !displayVideo?.available }">
|
<span class="badge" :class="{ bad: !video?.available }">
|
||||||
{{ modeLabel }}
|
{{ video?.source_mode ?? 'loading' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -450,82 +66,35 @@ watch(
|
|||||||
<img
|
<img
|
||||||
v-if="canRequestFrames"
|
v-if="canRequestFrames"
|
||||||
class="video-frame"
|
class="video-frame"
|
||||||
:src="frameUrl"
|
:src="streamUrl"
|
||||||
:alt="t('videoPanel.frameAlt')"
|
alt="Robot jpeg frame stream"
|
||||||
/>
|
/>
|
||||||
<div v-else class="video-placeholder">
|
<div v-else class="video-placeholder">
|
||||||
{{ t('videoPanel.waitingFrames') }}
|
{{ placeholderText }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('videoPanel.stats.frames') }}</span>
|
<span>帧源</span>
|
||||||
<strong>{{ displayVideo?.frame_count ?? 0 }}</strong>
|
<strong>{{ video?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>{{ t('videoPanel.stats.latestSeq') }}</span>
|
<span>当前模式</span>
|
||||||
<strong>{{ displayVideo?.receiver?.latest_sequence ?? '--' }}</strong>
|
<div class="stat-lines">
|
||||||
</div>
|
<strong>{{ senderProfileLabel }}</strong>
|
||||||
<div class="stat-card">
|
<strong class="secondary">{{ displayModeLabel }}</strong>
|
||||||
<span>{{ t('videoPanel.stats.videoE2E') }}</span>
|
|
||||||
<strong>{{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span>{{ t('videoPanel.stats.paintDelay') }}</span>
|
|
||||||
<strong>{{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-group">
|
|
||||||
<h3>{{ t('videoPanel.section.pipeline') }}</h3>
|
|
||||||
<p><strong>{{ t('videoPanel.captureToSend') }}:</strong> {{ formatNumber(displayVideo?.receiver?.latest_capture_to_send_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.networkOneWay') }}:</strong> {{ formatNumber(networkEstimate?.video_network_oneway_est_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.partialEstimate') }}:</strong> {{ formatNumber(networkEstimate?.video_partial_est_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.endToEndEstimate') }}:</strong> {{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-group">
|
|
||||||
<h3>{{ t('videoPanel.section.freshness') }}</h3>
|
|
||||||
<p><strong>{{ t('videoPanel.interFrameAvg') }}:</strong> {{ formatNumber(freshness?.inter_frame_avg_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.interFrameP95') }}:</strong> {{ formatNumber(freshness?.inter_frame_p95_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.repeatedRatio') }}:</strong> {{ formatNumber((freshness?.repeated_frame_ratio ?? 0) * 100, ' %') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.skipRatio') }}:</strong> {{ formatNumber((freshness?.skip_ratio ?? 0) * 100, ' %') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.longestFreeze') }}:</strong> {{ formatNumber(freshness?.longest_freeze_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.lagFrames') }}:</strong> {{ freshness?.relative_freshness_lag_frames ?? 0 }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-group">
|
|
||||||
<h3>{{ t('videoPanel.section.operator') }}</h3>
|
|
||||||
<p><strong>{{ t('videoPanel.inputToNextSeq') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_fresh_frame_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.inputToChangedFrame') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_changed_frame_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.inputToPaint') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_paint_ms, ' ms') }}</p>
|
|
||||||
<p><strong>{{ t('videoPanel.displayProbeRequestToPaint') }}:</strong> {{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timing-panel">
|
|
||||||
<div class="timing-head">
|
|
||||||
<span>{{ t('videoPanel.senderClockDelta') }}</span>
|
|
||||||
<strong>{{ timingHeadline }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="timing-grid">
|
|
||||||
<span
|
|
||||||
v-for="(sample, index) in (senderClockDebug?.sender_clock_delta_samples_ms_raw ?? [])"
|
|
||||||
:key="index"
|
|
||||||
class="timing-label"
|
|
||||||
>
|
|
||||||
{{ `${sample.toFixed(1)} ms` }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="hint subtle">
|
|
||||||
{{ timingHint }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{{ displayVideo?.source_detail ?? t('videoPanel.noSourceDetail') }}
|
视频可用时,页面会直接连接后端的 MJPEG stream,而不是按当前发送 fps 逐帧轮询。
|
||||||
|
这样能减少 dashboard 本身带来的额外显示延迟。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="hint subtle">
|
||||||
|
当前帧源状态:{{ video?.source_detail ?? '暂无' }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -547,24 +116,16 @@ watch(
|
|||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
color: #5b7aff;
|
color: #5b7aff;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.12em;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2,
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -581,9 +142,9 @@ h3 {
|
|||||||
|
|
||||||
.video-shell {
|
.video-shell {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||||
background: #050812;
|
background: linear-gradient(180deg, #09111f 0%, #050812 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-frame {
|
.video-frame {
|
||||||
@@ -591,106 +152,69 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
background: #02050d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder {
|
.video-placeholder {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
color: #95a4c6;
|
padding: 24px;
|
||||||
}
|
color: #a8b4ce;
|
||||||
|
text-align: center;
|
||||||
.stats,
|
line-height: 1.7;
|
||||||
.metric-grid {
|
background:
|
||||||
display: grid;
|
radial-gradient(circle at top, rgba(91, 122, 255, 0.14), transparent 42%),
|
||||||
gap: 10px;
|
#02050d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.stat-card {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
padding: 14px;
|
||||||
}
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 14, 26, 0.78);
|
||||||
.stat-card,
|
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||||
.metric-group,
|
|
||||||
.timing-panel {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
|
||||||
background: rgba(7, 14, 26, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card span,
|
|
||||||
.metric-group p,
|
|
||||||
.hint {
|
|
||||||
color: #d5dbee;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card span {
|
.stat-card span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
|
color: #8d99b3;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9aaccc;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card strong {
|
.stat-card strong {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-group {
|
.stat-lines {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-group p {
|
.stat-lines .secondary {
|
||||||
margin: 0;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
color: #a8b4ce;
|
||||||
}
|
|
||||||
|
|
||||||
.timing-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timing-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timing-label {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(91, 122, 255, 0.12);
|
|
||||||
color: #dbe5ff;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.7;
|
color: #8d99b3;
|
||||||
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint.subtle {
|
.hint.subtle {
|
||||||
color: #96a5c3;
|
font-size: 13px;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.stats,
|
|
||||||
.metric-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.stats,
|
.stats {
|
||||||
.metric-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,748 +0,0 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
|
|
||||||
import { buildControlWebSocketUrl } from '@/lib/api'
|
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
|
|
||||||
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: 'Space',
|
|
||||||
}
|
|
||||||
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 lastServerMessageOverride = ref('')
|
|
||||||
const lastServerMessagePreset = ref<'waiting' | 'live'>('waiting')
|
|
||||||
const gamepadSupported = ref(false)
|
|
||||||
const gamepadConnected = ref(false)
|
|
||||||
const gamepadNameRaw = ref('')
|
|
||||||
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')
|
|
||||||
const operatorInputSequence = ref(0)
|
|
||||||
const lastOperatorInputPerfMs = ref(0)
|
|
||||||
|
|
||||||
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 noteOperatorInput() {
|
|
||||||
operatorInputSequence.value += 1
|
|
||||||
lastOperatorInputPerfMs.value = performance.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
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, noteInput = true) {
|
|
||||||
const source = resolvedSource()
|
|
||||||
const values = resolvedCommandValues()
|
|
||||||
const signature = commandSignature(values, source)
|
|
||||||
|
|
||||||
if (!force && signature === lastCommandSignature) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastCommandSignature = signature
|
|
||||||
if (noteInput) {
|
|
||||||
noteOperatorInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
gamepadNameRaw.value = ''
|
|
||||||
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
|
|
||||||
gamepadNameRaw.value = pad.id || ''
|
|
||||||
gamepadIndex.value = pad.index
|
|
||||||
gamepadMapping.value = pad.mapping || ''
|
|
||||||
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'
|
|
||||||
lastServerMessagePreset.value = 'live'
|
|
||||||
lastServerMessageOverride.value = ''
|
|
||||||
refreshSendLoop(true, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
lastServerMessageOverride.value = event.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
socketState.value = 'closed'
|
|
||||||
lastServerMessagePreset.value = 'waiting'
|
|
||||||
lastServerMessageOverride.value = ''
|
|
||||||
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 t('control.socket.open')
|
|
||||||
if (socketState.value === 'connecting') return t('control.socket.connecting')
|
|
||||||
return t('control.socket.reconnecting')
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeSourceLabel = computed(() => {
|
|
||||||
if (activeSource.value === 'keyboard') return t('common.keyboard')
|
|
||||||
if (activeSource.value === 'gamepad') return t('common.gamepad')
|
|
||||||
return t('common.idle')
|
|
||||||
})
|
|
||||||
|
|
||||||
const controlInputModeLabel = computed(() => {
|
|
||||||
if (controlInputMode.value === 'gamepad') return t('common.gamepad')
|
|
||||||
return t('common.keyboard')
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastServerMessage = computed(() => {
|
|
||||||
if (lastServerMessageOverride.value) {
|
|
||||||
return lastServerMessageOverride.value
|
|
||||||
}
|
|
||||||
return lastServerMessagePreset.value === 'live' ? t('control.server.live') : t('control.server.waiting')
|
|
||||||
})
|
|
||||||
|
|
||||||
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(', ') || t('common.none'))
|
|
||||||
|
|
||||||
const keyboardKeys = computed<KeyFeedback[]>(() =>
|
|
||||||
TRACKED_KEYS.map((code) => ({
|
|
||||||
code,
|
|
||||||
label: code === 'Space' ? t('control.key.stop') : (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 gamepadName = computed(() => {
|
|
||||||
if (!gamepadConnected.value) {
|
|
||||||
return t('control.gamepad.none')
|
|
||||||
}
|
|
||||||
return gamepadNameRaw.value || t('control.gamepad.unnamed')
|
|
||||||
})
|
|
||||||
|
|
||||||
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()),
|
|
||||||
operatorInputSequence,
|
|
||||||
lastOperatorInputPerfMs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOperatorInputTelemetry() {
|
|
||||||
return {
|
|
||||||
operatorInputSequence,
|
|
||||||
lastOperatorInputPerfMs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchDashboardSnapshot } from '@/lib/api'
|
import { fetchDashboardSnapshot } from '@/lib/api'
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
|
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
|
||||||
|
|
||||||
type UseMonitoringDataOptions = {
|
type UseMonitoringDataOptions = {
|
||||||
@@ -14,7 +13,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
|||||||
const video = ref<VideoStatus | null>(null)
|
const video = ref<VideoStatus | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000)
|
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 500)
|
||||||
|
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
|||||||
video.value = snapshot.video
|
video.value = snapshot.video
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : t('common.requestFailed', { status: '-', statusText: '' })
|
errorMessage.value = error instanceof Error ? error.message : '数据加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -37,9 +36,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
|||||||
return errorMessage.value
|
return errorMessage.value
|
||||||
}
|
}
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return t('monitoring.loading')
|
return '正在连接 Django 后端并加载监控数据...'
|
||||||
}
|
}
|
||||||
return t('monitoring.connected')
|
return '页面已连接 Django 后端。GPS 与网络状态按当前页面策略轮询更新,视频区域单独按目标 30FPS 请求单帧 JPEG。'
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { DashboardSnapshot, VideoStatus } from '@/types'
|
import type { DashboardSnapshot } from '@/types'
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
|
|
||||||
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||||
|
|
||||||
@@ -8,7 +7,7 @@ export const API_BASE = (envBaseUrl?.trim() || 'http://127.0.0.1:8001').replace(
|
|||||||
async function fetchJson<T>(path: string): Promise<T> {
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`)
|
const response = await fetch(`${API_BASE}${path}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(t('common.requestFailed', { status: response.status, statusText: response.statusText }))
|
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>
|
return response.json() as Promise<T>
|
||||||
}
|
}
|
||||||
@@ -17,46 +16,10 @@ export function fetchDashboardSnapshot() {
|
|||||||
return fetchJson<DashboardSnapshot>('/api/dashboard/')
|
return fetchJson<DashboardSnapshot>('/api/dashboard/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchVideoStatus() {
|
|
||||||
return fetchJson<VideoStatus>('/api/video/status/')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchClockCalibrationSample() {
|
|
||||||
const response = await fetch(`${API_BASE}/api/clock/calibrate/`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`clock calibration failed: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
return response.json() as Promise<{
|
|
||||||
server_received_unix_ms: number
|
|
||||||
server_sent_unix_ms: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVideoFrameUrl(frameKey: number) {
|
export function buildVideoFrameUrl(frameKey: number) {
|
||||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postVideoDisplayProbe(payload: Record<string, unknown>) {
|
export function buildVideoStreamUrl(fps: number, token: number) {
|
||||||
const response = await fetch(`${API_BASE}/api/video/display-probe/`, {
|
return `${API_BASE}/api/video/stream/?fps=${fps}&t=${token}`
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`display probe post failed: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,512 +0,0 @@
|
|||||||
import { computed, readonly, ref } from 'vue'
|
|
||||||
|
|
||||||
export type Locale = 'zh-CN' | 'en-US'
|
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'robot-command-center.locale'
|
|
||||||
const DEFAULT_LOCALE: Locale = 'zh-CN'
|
|
||||||
|
|
||||||
const zhCNMessages = {
|
|
||||||
'common.loading': '加载中',
|
|
||||||
'common.waiting': '等待中',
|
|
||||||
'common.unavailable': '不可用',
|
|
||||||
'common.unknown': '未知',
|
|
||||||
'common.none': '无',
|
|
||||||
'common.na': 'n/a',
|
|
||||||
'common.yes': '是',
|
|
||||||
'common.no': '否',
|
|
||||||
'common.keyboard': '键盘',
|
|
||||||
'common.gamepad': '手柄',
|
|
||||||
'common.idle': '空闲',
|
|
||||||
'common.control': '控制',
|
|
||||||
'common.video': '视频',
|
|
||||||
'common.online': '在线',
|
|
||||||
'common.offline': '离线',
|
|
||||||
'common.fresh': '新鲜',
|
|
||||||
'common.stale': '过期',
|
|
||||||
'common.stable': '稳定',
|
|
||||||
'common.rising': '上升',
|
|
||||||
'common.falling': '下降',
|
|
||||||
'common.selected': '已选中',
|
|
||||||
'common.standby': '待命',
|
|
||||||
'common.turbo': '加速',
|
|
||||||
'common.ackLoop': 'ACK 闭环',
|
|
||||||
'common.srttFallback': 'SRTT 回退',
|
|
||||||
'common.requestFailed': '请求失败: {status} {statusText}',
|
|
||||||
'app.brandTitle': '机器人指挥中心',
|
|
||||||
'app.brandSubtitle': '远程机器人控制台',
|
|
||||||
'app.nav.overview': '概览',
|
|
||||||
'app.nav.video': '视频',
|
|
||||||
'app.nav.map': '地图定位',
|
|
||||||
'app.nav.network': '网络状态',
|
|
||||||
'app.localeToggle': 'English',
|
|
||||||
'dashboard.eyebrow': '概览',
|
|
||||||
'dashboard.title': '机器人指挥中心',
|
|
||||||
'dashboard.description': 'A 端统一后台进程持续刷新视频、控制仲裁和链路遥测。',
|
|
||||||
'networkView.eyebrow': '网络',
|
|
||||||
'networkView.title': '网络遥测',
|
|
||||||
'networkView.description': '查看 A <-> D 与 D <-> B 两段链路的实时队列、重传、窗口压力和延迟估计。',
|
|
||||||
'videoView.eyebrow': '视频',
|
|
||||||
'videoView.title': '视频监控',
|
|
||||||
'videoView.description': '查看机器人实时 JPEG 视频流、画面新鲜度和端到端延迟估计。',
|
|
||||||
'mapView.eyebrow': '地图',
|
|
||||||
'mapView.title': '地图定位',
|
|
||||||
'mapView.description': '查看机器人最新 GPS 数据,并按需使用高德地图做坐标转换和展示。',
|
|
||||||
'monitoring.loading': '正在连接 Django 后端并加载实时监控数据...',
|
|
||||||
'monitoring.connected': '仪表盘已连接。视频、GPS 和会话遥测正在持续刷新。',
|
|
||||||
'control.socket.open': 'WebSocket 已连接',
|
|
||||||
'control.socket.connecting': '连接中',
|
|
||||||
'control.socket.reconnecting': '重连中',
|
|
||||||
'control.server.waiting': '等待控制链路就绪',
|
|
||||||
'control.server.live': '控制链路已建立',
|
|
||||||
'control.gamepad.none': '未检测到手柄',
|
|
||||||
'control.gamepad.unnamed': '未命名手柄',
|
|
||||||
'control.gamepad.unknownMapping': '未知映射',
|
|
||||||
'control.key.stop': '停止',
|
|
||||||
'controlPanel.eyebrow': '控制',
|
|
||||||
'controlPanel.title': '控制反馈',
|
|
||||||
'controlPanel.resetDefaults': '恢复默认',
|
|
||||||
'controlPanel.inputModeEyebrow': '输入模式',
|
|
||||||
'controlPanel.inputModeCopy': '同一时刻只能有一种本地输入模式控制页面。',
|
|
||||||
'controlPanel.keyboardDetail': '使用 W/S、A/D、Q/E、Shift 和 Space。',
|
|
||||||
'controlPanel.gamepadDetail': '仅使用浏览器识别到的手柄。',
|
|
||||||
'controlPanel.forward': '前进',
|
|
||||||
'controlPanel.strafe': '横移',
|
|
||||||
'controlPanel.turn': '转向',
|
|
||||||
'controlPanel.turbo': '加速',
|
|
||||||
'controlPanel.keyboardHint': '键盘映射: W/S 前后, A/D 横移, Q/E 转向, Shift 加速, Space 停止。',
|
|
||||||
'controlPanel.tuningHint': '速度调节由两种本地输入模式共享,并保存在当前浏览器中。',
|
|
||||||
'controlPanel.gamepadHint': '手柄模式下左摇杆控制移动,右摇杆控制转向,RB 加速,A 发送停止。',
|
|
||||||
'controlFeedback.modeChip': '{mode} 模式',
|
|
||||||
'controlFeedback.forward': '前进',
|
|
||||||
'controlFeedback.strafe': '横移',
|
|
||||||
'controlFeedback.turn': '转向',
|
|
||||||
'controlFeedback.tuningSummary': '调参: 前进 {forward} m/s, 横移 {strafe} m/s, 转向 {turn} rad/s, 加速 x{turbo}',
|
|
||||||
'controlFeedback.keyboard': '键盘',
|
|
||||||
'controlFeedback.gamepad': '手柄',
|
|
||||||
'controlFeedback.waitingForController': '等待手柄接入',
|
|
||||||
'controlFeedback.gamepadMeta': '#{index} / 映射={mapping}',
|
|
||||||
'controlFeedback.gamepadHint': '左摇杆控制移动,右摇杆控制转向,RB 加速,A 停止。',
|
|
||||||
'controlFeedback.leftStick': '左摇杆',
|
|
||||||
'controlFeedback.rightStick': '右摇杆',
|
|
||||||
'controlFeedback.outgoingCommand': '当前发出命令: {command}',
|
|
||||||
'videoPanel.eyebrow': '视频',
|
|
||||||
'videoPanel.title': '实时视频',
|
|
||||||
'videoPanel.frameAlt': '机器人实时画面',
|
|
||||||
'videoPanel.waitingFrames': '等待实时视频帧',
|
|
||||||
'videoPanel.mode.loading': '加载中',
|
|
||||||
'videoPanel.mode.live': '{fps} FPS 实时',
|
|
||||||
'videoPanel.stats.frames': '帧数',
|
|
||||||
'videoPanel.stats.latestSeq': '最新序号',
|
|
||||||
'videoPanel.stats.videoE2E': '视频端到端估计',
|
|
||||||
'videoPanel.stats.paintDelay': '绘制延迟',
|
|
||||||
'videoPanel.section.pipeline': '流水线估计',
|
|
||||||
'videoPanel.section.freshness': '新鲜度',
|
|
||||||
'videoPanel.section.operator': '操作员闭环',
|
|
||||||
'videoPanel.captureToSend': '采集到发送',
|
|
||||||
'videoPanel.networkOneWay': '网络单程',
|
|
||||||
'videoPanel.partialEstimate': '部分估计',
|
|
||||||
'videoPanel.endToEndEstimate': '端到端估计',
|
|
||||||
'videoPanel.interFrameAvg': '帧间平均',
|
|
||||||
'videoPanel.interFrameP95': '帧间 p95',
|
|
||||||
'videoPanel.repeatedRatio': '重复比例',
|
|
||||||
'videoPanel.skipRatio': '跳帧比例',
|
|
||||||
'videoPanel.longestFreeze': '最长卡顿',
|
|
||||||
'videoPanel.lagFrames': '落后帧数',
|
|
||||||
'videoPanel.inputToNextSeq': '输入到下一新序号',
|
|
||||||
'videoPanel.inputToChangedFrame': '输入到下一变化帧',
|
|
||||||
'videoPanel.inputToPaint': '输入到下一次绘制',
|
|
||||||
'videoPanel.displayProbeRequestToPaint': '显示探针请求到绘制',
|
|
||||||
'videoPanel.senderClockDelta': '发送端时钟差',
|
|
||||||
'videoPanel.timing.waiting': '等待中',
|
|
||||||
'videoPanel.timing.noTrailer': '正在等待第一帧带有效 trailer 的视频数据',
|
|
||||||
'videoPanel.timing.rawHint': '这里只显示发送端原始时钟差,设备时钟未同步',
|
|
||||||
'videoPanel.noSourceDetail': '暂无实时视频详情',
|
|
||||||
'networkPanel.eyebrow': '网络',
|
|
||||||
'networkPanel.title': '双段链路遥测',
|
|
||||||
'networkPanel.controlLoopRtt': '控制闭环 RTT',
|
|
||||||
'networkPanel.controlToPersist': '控制到持久化',
|
|
||||||
'networkPanel.controlSrttOneWay': '控制单程 SRTT',
|
|
||||||
'networkPanel.videoOneWayEst': '视频单程估计',
|
|
||||||
'networkPanel.txRate': '发送速率',
|
|
||||||
'networkPanel.rxRate': '接收速率',
|
|
||||||
'networkPanel.robotFault': '机器人故障',
|
|
||||||
'networkPanel.recoveryState': '恢复状态',
|
|
||||||
'networkPanel.healthConfidence': '健康置信度',
|
|
||||||
'networkPanel.healthUpdated': '健康更新时间',
|
|
||||||
'networkPanel.transport': '传输',
|
|
||||||
'networkPanel.activeControl': '当前控制源',
|
|
||||||
'networkPanel.lease': '租约',
|
|
||||||
'networkPanel.ackMode': 'ACK 模式',
|
|
||||||
'networkPanel.ackUpdated': 'ACK 更新时间',
|
|
||||||
'networkPanel.telemetryPeer': '遥测 Peer',
|
|
||||||
'networkPanel.telemetryRegistered': '遥测已注册',
|
|
||||||
'networkPanel.hubFreshness': 'Hub 新鲜度',
|
|
||||||
'networkPanel.hubState': 'Hub 状态',
|
|
||||||
'networkPanel.telemetryReconnects': '遥测重连次数',
|
|
||||||
'networkPanel.hubError': 'Hub 错误',
|
|
||||||
'networkPanel.telemetrySessionError': '遥测会话错误',
|
|
||||||
'networkPanel.online': '在线',
|
|
||||||
'networkPanel.maxPressure': '最大压力',
|
|
||||||
'networkPanel.queued': '排队量',
|
|
||||||
'networkPanel.inFlightBuffer': '在途缓冲',
|
|
||||||
'networkPanel.retransDelta': '重传增量',
|
|
||||||
'networkPanel.repairRate': '修复率',
|
|
||||||
'networkPanel.updated': '更新时间',
|
|
||||||
'networkPanel.srtt': 'SRTT',
|
|
||||||
'networkPanel.rttvar': 'RTTVAR',
|
|
||||||
'networkPanel.rto': 'RTO',
|
|
||||||
'networkPanel.sndWnd': '发送窗口',
|
|
||||||
'networkPanel.rmtWnd': '远端窗口',
|
|
||||||
'networkPanel.inflight': '在途',
|
|
||||||
'networkPanel.windowLimit': '窗口上限',
|
|
||||||
'networkPanel.pressure': '压力',
|
|
||||||
'networkPanel.sndQueue': '发送队列',
|
|
||||||
'networkPanel.sndBuffer': '发送缓冲',
|
|
||||||
'networkPanel.queueDelta': '队列增量',
|
|
||||||
'networkPanel.bufferDelta': '缓冲增量',
|
|
||||||
'networkPanel.retrans': '重传',
|
|
||||||
'networkPanel.fastRetrans': '快速重传',
|
|
||||||
'networkPanel.lost': '丢失',
|
|
||||||
'networkPanel.repeat': '重复',
|
|
||||||
'networkPanel.appBytes': '应用字节',
|
|
||||||
'networkPanel.registered': '已注册',
|
|
||||||
'networkPanel.serverError': '服务端错误',
|
|
||||||
'networkPanel.combined': '总计',
|
|
||||||
'networkPanel.videoE2E': '视频端到端估计',
|
|
||||||
'networkPanel.controlEstimateConfidence': '控制估计置信度',
|
|
||||||
'networkPanel.videoFreshness': '视频新鲜度',
|
|
||||||
'networkPanel.videoFreshnessRepeat': '重复',
|
|
||||||
'networkPanel.videoFreshnessSkip': '跳帧',
|
|
||||||
'networkPanel.videoFreshnessFreeze': '卡顿',
|
|
||||||
'networkPanel.nativeUdp': '原生 UDP',
|
|
||||||
'networkPanel.controlSender': '控制发送端',
|
|
||||||
'networkPanel.ackReceiver': 'ACK 接收端',
|
|
||||||
'networkPanel.controlReconnects': '控制重连次数',
|
|
||||||
'networkPanel.controlSessionError': '控制会话错误',
|
|
||||||
'networkPanel.loadingPeer': '加载中',
|
|
||||||
'networkPanel.unassigned': '未分配',
|
|
||||||
'gpsMap.eyebrow': 'GPS',
|
|
||||||
'gpsMap.title': '地图定位',
|
|
||||||
'gpsMap.intro': '这里展示机器人最新的 GPS 定位,并在需要时调用高德地图做坐标转换。',
|
|
||||||
'gpsMap.keyPlaceholder': '高德 Web 端 Key',
|
|
||||||
'gpsMap.jscodePlaceholder': '安全密钥 jscode',
|
|
||||||
'gpsMap.loadMap': '加载地图',
|
|
||||||
'gpsMap.stopMap': '停止加载',
|
|
||||||
'gpsMap.status.waitingInit': '等待加载高德地图。',
|
|
||||||
'gpsMap.status.fillCredentials': '请先填写高德 Key 和安全密钥 jscode。',
|
|
||||||
'gpsMap.status.loading': '正在加载高德地图...',
|
|
||||||
'gpsMap.status.loaded': '地图已加载。',
|
|
||||||
'gpsMap.status.stopped': '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。',
|
|
||||||
'gpsMap.status.waitingGps': '等待 GPS 数据。',
|
|
||||||
'gpsMap.status.noFix': 'GPS 在线,但当前还没有有效定位。',
|
|
||||||
'gpsMap.status.convertFailed': 'GPS 坐标转换失败。',
|
|
||||||
'gpsMap.status.refreshedSource': '地图已刷新,数据源: {source}',
|
|
||||||
'gpsMap.status.restoredConfig': '已恢复高德配置。地图不会自动加载,按需点击“加载地图”。',
|
|
||||||
'gpsMap.status.loadFailed': '地图加载失败。',
|
|
||||||
'gpsMap.mapPlaceholder': '高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。',
|
|
||||||
'gpsMap.wgs84': 'WGS84 坐标',
|
|
||||||
'gpsMap.gcj02': '高德 GCJ-02',
|
|
||||||
'gpsMap.rawLatHex': '纬度原始 8 字节',
|
|
||||||
'gpsMap.rawLonHex': '经度原始 8 字节',
|
|
||||||
'gpsMap.utcTime': 'UTC 时间',
|
|
||||||
'gpsMap.satAltitude': '卫星 / 海拔',
|
|
||||||
'gpsMap.coordMeta': '坐标系 / 格式',
|
|
||||||
'gpsMap.lastUpdated': '最近刷新',
|
|
||||||
'gpsMap.noValue': '暂无',
|
|
||||||
'gpsMap.noValidFix': '暂无有效定位',
|
|
||||||
'gpsMap.infoTitle': '机器人 GPS 定位',
|
|
||||||
'gpsMap.infoSatellites': '卫星数',
|
|
||||||
'gpsMap.infoAltitude': '海拔',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type MessageKey = keyof typeof zhCNMessages
|
|
||||||
|
|
||||||
const enUSMessages: Record<MessageKey, string> = {
|
|
||||||
'common.loading': 'Loading',
|
|
||||||
'common.waiting': 'Waiting',
|
|
||||||
'common.unavailable': 'Unavailable',
|
|
||||||
'common.unknown': 'Unknown',
|
|
||||||
'common.none': 'None',
|
|
||||||
'common.na': 'n/a',
|
|
||||||
'common.yes': 'Yes',
|
|
||||||
'common.no': 'No',
|
|
||||||
'common.keyboard': 'Keyboard',
|
|
||||||
'common.gamepad': 'Gamepad',
|
|
||||||
'common.idle': 'Idle',
|
|
||||||
'common.control': 'Control',
|
|
||||||
'common.video': 'Video',
|
|
||||||
'common.online': 'Online',
|
|
||||||
'common.offline': 'Offline',
|
|
||||||
'common.fresh': 'Fresh',
|
|
||||||
'common.stale': 'Stale',
|
|
||||||
'common.stable': 'Stable',
|
|
||||||
'common.rising': 'Rising',
|
|
||||||
'common.falling': 'Falling',
|
|
||||||
'common.selected': 'Selected',
|
|
||||||
'common.standby': 'Standby',
|
|
||||||
'common.turbo': 'Turbo',
|
|
||||||
'common.ackLoop': 'ACK loop',
|
|
||||||
'common.srttFallback': 'SRTT fallback',
|
|
||||||
'common.requestFailed': 'Request failed: {status} {statusText}',
|
|
||||||
'app.brandTitle': 'Robot Command Center',
|
|
||||||
'app.brandSubtitle': 'Remote robot command console',
|
|
||||||
'app.nav.overview': 'Overview',
|
|
||||||
'app.nav.video': 'Video',
|
|
||||||
'app.nav.map': 'Map',
|
|
||||||
'app.nav.network': 'Network',
|
|
||||||
'app.localeToggle': '中文',
|
|
||||||
'dashboard.eyebrow': 'Overview',
|
|
||||||
'dashboard.title': 'Robot Command Center',
|
|
||||||
'dashboard.description': 'The A-side unified backend keeps video, control arbitration, and live transport telemetry refreshed.',
|
|
||||||
'networkView.eyebrow': 'Network',
|
|
||||||
'networkView.title': 'Network Telemetry',
|
|
||||||
'networkView.description': 'Inspect queueing, retransmissions, window pressure, and latency estimates for the A <-> D and D <-> B legs.',
|
|
||||||
'videoView.eyebrow': 'Video',
|
|
||||||
'videoView.title': 'Video Monitor',
|
|
||||||
'videoView.description': 'Inspect the live robot JPEG stream, freshness metrics, and end-to-end latency estimates.',
|
|
||||||
'mapView.eyebrow': 'Map',
|
|
||||||
'mapView.title': 'Map Positioning',
|
|
||||||
'mapView.description': 'Inspect the latest robot GPS fix and use AMap for coordinate conversion when needed.',
|
|
||||||
'monitoring.loading': 'Connecting to the Django backend and loading live monitoring data...',
|
|
||||||
'monitoring.connected': 'Dashboard connected. Video, GPS, and session telemetry are refreshing continuously.',
|
|
||||||
'control.socket.open': 'WebSocket open',
|
|
||||||
'control.socket.connecting': 'Connecting',
|
|
||||||
'control.socket.reconnecting': 'Reconnecting',
|
|
||||||
'control.server.waiting': 'Waiting for control link',
|
|
||||||
'control.server.live': 'Control link live',
|
|
||||||
'control.gamepad.none': 'No gamepad detected',
|
|
||||||
'control.gamepad.unnamed': 'Unnamed gamepad',
|
|
||||||
'control.gamepad.unknownMapping': 'unknown',
|
|
||||||
'control.key.stop': 'Stop',
|
|
||||||
'controlPanel.eyebrow': 'Control',
|
|
||||||
'controlPanel.title': 'Control Feedback',
|
|
||||||
'controlPanel.resetDefaults': 'Reset Defaults',
|
|
||||||
'controlPanel.inputModeEyebrow': 'Input Mode',
|
|
||||||
'controlPanel.inputModeCopy': 'Only one local input mode can control the page at a time.',
|
|
||||||
'controlPanel.keyboardDetail': 'Use W/S, A/D, Q/E, Shift, and Space.',
|
|
||||||
'controlPanel.gamepadDetail': 'Use the browser-detected controller only.',
|
|
||||||
'controlPanel.forward': 'Forward',
|
|
||||||
'controlPanel.strafe': 'Strafe',
|
|
||||||
'controlPanel.turn': 'Turn',
|
|
||||||
'controlPanel.turbo': 'Turbo',
|
|
||||||
'controlPanel.keyboardHint': 'Keyboard mapping: W/S forward-back, A/D strafe, Q/E turn, Shift turbo, Space stop.',
|
|
||||||
'controlPanel.tuningHint': 'Speed tuning is shared by both local input modes and saved in this browser.',
|
|
||||||
'controlPanel.gamepadHint': 'Gamepad mode uses the left stick to drive, the right stick to turn, RB to boost, and A to stop.',
|
|
||||||
'controlFeedback.modeChip': '{mode} mode',
|
|
||||||
'controlFeedback.forward': 'Forward',
|
|
||||||
'controlFeedback.strafe': 'Strafe',
|
|
||||||
'controlFeedback.turn': 'Turn',
|
|
||||||
'controlFeedback.tuningSummary': 'Tuning: fwd {forward} m/s, strafe {strafe} m/s, turn {turn} rad/s, turbo x{turbo}',
|
|
||||||
'controlFeedback.keyboard': 'Keyboard',
|
|
||||||
'controlFeedback.gamepad': 'Gamepad',
|
|
||||||
'controlFeedback.waitingForController': 'Waiting for controller',
|
|
||||||
'controlFeedback.gamepadMeta': '#{index} / mapping={mapping}',
|
|
||||||
'controlFeedback.gamepadHint': 'Left stick drives, right stick turns, RB boosts, A stops.',
|
|
||||||
'controlFeedback.leftStick': 'Left stick',
|
|
||||||
'controlFeedback.rightStick': 'Right stick',
|
|
||||||
'controlFeedback.outgoingCommand': 'Outgoing command: {command}',
|
|
||||||
'videoPanel.eyebrow': 'Video',
|
|
||||||
'videoPanel.title': 'Live Video',
|
|
||||||
'videoPanel.frameAlt': 'Robot live frame',
|
|
||||||
'videoPanel.waitingFrames': 'waiting for live video frames',
|
|
||||||
'videoPanel.mode.loading': 'loading',
|
|
||||||
'videoPanel.mode.live': '{fps} FPS live',
|
|
||||||
'videoPanel.stats.frames': 'Frames',
|
|
||||||
'videoPanel.stats.latestSeq': 'Latest Seq',
|
|
||||||
'videoPanel.stats.videoE2E': 'Video E2E Est.',
|
|
||||||
'videoPanel.stats.paintDelay': 'Paint Delay',
|
|
||||||
'videoPanel.section.pipeline': 'Pipeline Estimate',
|
|
||||||
'videoPanel.section.freshness': 'Freshness',
|
|
||||||
'videoPanel.section.operator': 'Operator Loop',
|
|
||||||
'videoPanel.captureToSend': 'Capture to send',
|
|
||||||
'videoPanel.networkOneWay': 'Network one-way',
|
|
||||||
'videoPanel.partialEstimate': 'Partial estimate',
|
|
||||||
'videoPanel.endToEndEstimate': 'End-to-end estimate',
|
|
||||||
'videoPanel.interFrameAvg': 'Inter-frame avg',
|
|
||||||
'videoPanel.interFrameP95': 'Inter-frame p95',
|
|
||||||
'videoPanel.repeatedRatio': 'Repeated ratio',
|
|
||||||
'videoPanel.skipRatio': 'Skip ratio',
|
|
||||||
'videoPanel.longestFreeze': 'Longest freeze',
|
|
||||||
'videoPanel.lagFrames': 'Lag frames',
|
|
||||||
'videoPanel.inputToNextSeq': 'Input to next seq',
|
|
||||||
'videoPanel.inputToChangedFrame': 'Input to changed frame',
|
|
||||||
'videoPanel.inputToPaint': 'Input to paint',
|
|
||||||
'videoPanel.displayProbeRequestToPaint': 'Display probe request-to-paint',
|
|
||||||
'videoPanel.senderClockDelta': 'Sender Clock Delta',
|
|
||||||
'videoPanel.timing.waiting': 'waiting',
|
|
||||||
'videoPanel.timing.noTrailer': 'waiting for the first valid video trailer',
|
|
||||||
'videoPanel.timing.rawHint': 'raw sender clock delta only, unsynced clocks',
|
|
||||||
'videoPanel.noSourceDetail': 'no live video detail available',
|
|
||||||
'networkPanel.eyebrow': 'Network',
|
|
||||||
'networkPanel.title': 'Dual-Leg Telemetry',
|
|
||||||
'networkPanel.controlLoopRtt': 'Control Loop RTT',
|
|
||||||
'networkPanel.controlToPersist': 'Control to Persist',
|
|
||||||
'networkPanel.controlSrttOneWay': 'Control SRTT One-way',
|
|
||||||
'networkPanel.videoOneWayEst': 'Video One-way Est.',
|
|
||||||
'networkPanel.txRate': 'TX Rate',
|
|
||||||
'networkPanel.rxRate': 'RX Rate',
|
|
||||||
'networkPanel.robotFault': 'Robot Fault',
|
|
||||||
'networkPanel.recoveryState': 'Recovery State',
|
|
||||||
'networkPanel.healthConfidence': 'Health Confidence',
|
|
||||||
'networkPanel.healthUpdated': 'Health Updated',
|
|
||||||
'networkPanel.transport': 'Transport',
|
|
||||||
'networkPanel.activeControl': 'Active Control',
|
|
||||||
'networkPanel.lease': 'Lease',
|
|
||||||
'networkPanel.ackMode': 'ACK Mode',
|
|
||||||
'networkPanel.ackUpdated': 'ACK Updated',
|
|
||||||
'networkPanel.telemetryPeer': 'Telemetry Peer',
|
|
||||||
'networkPanel.telemetryRegistered': 'Telemetry Registered',
|
|
||||||
'networkPanel.hubFreshness': 'Hub Freshness',
|
|
||||||
'networkPanel.hubState': 'Hub State',
|
|
||||||
'networkPanel.telemetryReconnects': 'Telemetry Reconnects',
|
|
||||||
'networkPanel.hubError': 'Hub Error',
|
|
||||||
'networkPanel.telemetrySessionError': 'Telemetry Session Error',
|
|
||||||
'networkPanel.online': 'Online',
|
|
||||||
'networkPanel.maxPressure': 'Max Pressure',
|
|
||||||
'networkPanel.queued': 'Queued',
|
|
||||||
'networkPanel.inFlightBuffer': 'In Flight Buffer',
|
|
||||||
'networkPanel.retransDelta': 'Retrans Delta',
|
|
||||||
'networkPanel.repairRate': 'Repair Rate',
|
|
||||||
'networkPanel.updated': 'Updated',
|
|
||||||
'networkPanel.srtt': 'SRTT',
|
|
||||||
'networkPanel.rttvar': 'RTTVAR',
|
|
||||||
'networkPanel.rto': 'RTO',
|
|
||||||
'networkPanel.sndWnd': 'SND WND',
|
|
||||||
'networkPanel.rmtWnd': 'RMT WND',
|
|
||||||
'networkPanel.inflight': 'Inflight',
|
|
||||||
'networkPanel.windowLimit': 'Window Limit',
|
|
||||||
'networkPanel.pressure': 'Pressure',
|
|
||||||
'networkPanel.sndQueue': 'SND Queue',
|
|
||||||
'networkPanel.sndBuffer': 'SND Buffer',
|
|
||||||
'networkPanel.queueDelta': 'Queue Delta',
|
|
||||||
'networkPanel.bufferDelta': 'Buffer Delta',
|
|
||||||
'networkPanel.retrans': 'Retrans',
|
|
||||||
'networkPanel.fastRetrans': 'Fast Retrans',
|
|
||||||
'networkPanel.lost': 'Lost',
|
|
||||||
'networkPanel.repeat': 'Repeat',
|
|
||||||
'networkPanel.appBytes': 'App Bytes',
|
|
||||||
'networkPanel.registered': 'Registered',
|
|
||||||
'networkPanel.serverError': 'Server Error',
|
|
||||||
'networkPanel.combined': 'Combined',
|
|
||||||
'networkPanel.videoE2E': 'Video E2E Est.',
|
|
||||||
'networkPanel.controlEstimateConfidence': 'Control Estimate Confidence',
|
|
||||||
'networkPanel.videoFreshness': 'Video Freshness',
|
|
||||||
'networkPanel.videoFreshnessRepeat': 'repeat',
|
|
||||||
'networkPanel.videoFreshnessSkip': 'skip',
|
|
||||||
'networkPanel.videoFreshnessFreeze': 'freeze',
|
|
||||||
'networkPanel.nativeUdp': 'Native UDP',
|
|
||||||
'networkPanel.controlSender': 'Control Sender',
|
|
||||||
'networkPanel.ackReceiver': 'ACK Receiver',
|
|
||||||
'networkPanel.controlReconnects': 'Control Reconnects',
|
|
||||||
'networkPanel.controlSessionError': 'Control Session Error',
|
|
||||||
'networkPanel.loadingPeer': 'loading',
|
|
||||||
'networkPanel.unassigned': 'unassigned',
|
|
||||||
'gpsMap.eyebrow': 'GPS',
|
|
||||||
'gpsMap.title': 'Map Positioning',
|
|
||||||
'gpsMap.intro': 'This panel displays the latest robot GPS fix and uses AMap for coordinate conversion when needed.',
|
|
||||||
'gpsMap.keyPlaceholder': 'AMap Web Key',
|
|
||||||
'gpsMap.jscodePlaceholder': 'Security jscode',
|
|
||||||
'gpsMap.loadMap': 'Load Map',
|
|
||||||
'gpsMap.stopMap': 'Stop Loading',
|
|
||||||
'gpsMap.status.waitingInit': 'Waiting to load AMap.',
|
|
||||||
'gpsMap.status.fillCredentials': 'Please enter the AMap key and security jscode first.',
|
|
||||||
'gpsMap.status.loading': 'Loading AMap...',
|
|
||||||
'gpsMap.status.loaded': 'Map loaded.',
|
|
||||||
'gpsMap.status.stopped': 'Stopped AMap loading and coordinate conversion. Click "Load Map" again when needed.',
|
|
||||||
'gpsMap.status.waitingGps': 'Waiting for GPS data.',
|
|
||||||
'gpsMap.status.noFix': 'GPS is online, but there is no valid fix yet.',
|
|
||||||
'gpsMap.status.convertFailed': 'GPS coordinate conversion failed.',
|
|
||||||
'gpsMap.status.refreshedSource': 'Map refreshed, source: {source}',
|
|
||||||
'gpsMap.status.restoredConfig': 'Recovered saved AMap config. The map will not auto-load; click "Load Map" when needed.',
|
|
||||||
'gpsMap.status.loadFailed': 'Map loading failed.',
|
|
||||||
'gpsMap.mapPlaceholder': 'AMap is not loaded right now. Click "Load Map" above before requesting map and coordinate conversion services.',
|
|
||||||
'gpsMap.wgs84': 'WGS84 Coordinates',
|
|
||||||
'gpsMap.gcj02': 'AMap GCJ-02',
|
|
||||||
'gpsMap.rawLatHex': 'Raw Latitude 8 Bytes',
|
|
||||||
'gpsMap.rawLonHex': 'Raw Longitude 8 Bytes',
|
|
||||||
'gpsMap.utcTime': 'UTC Time',
|
|
||||||
'gpsMap.satAltitude': 'Satellites / Altitude',
|
|
||||||
'gpsMap.coordMeta': 'Coordinate System / Format',
|
|
||||||
'gpsMap.lastUpdated': 'Last Updated',
|
|
||||||
'gpsMap.noValue': 'Unavailable',
|
|
||||||
'gpsMap.noValidFix': 'No valid fix',
|
|
||||||
'gpsMap.infoTitle': 'Robot GPS Position',
|
|
||||||
'gpsMap.infoSatellites': 'Satellites',
|
|
||||||
'gpsMap.infoAltitude': 'Altitude',
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages: Record<Locale, Record<MessageKey, string>> = {
|
|
||||||
'zh-CN': zhCNMessages,
|
|
||||||
'en-US': enUSMessages,
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLocale(raw: unknown): Locale {
|
|
||||||
return raw === 'en-US' ? 'en-US' : DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStoredLocale(): Locale {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return normalizeLocale(window.localStorage.getItem(LOCALE_STORAGE_KEY))
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localeState = ref<Locale>(loadStoredLocale())
|
|
||||||
|
|
||||||
function storeLocale(locale: Locale) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
|
||||||
} catch {
|
|
||||||
// Ignore storage failures; locale still works for current session.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolate(template: string, params?: Record<string, string | number | null | undefined>) {
|
|
||||||
if (!params) {
|
|
||||||
return template
|
|
||||||
}
|
|
||||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function t(key: MessageKey, params?: Record<string, string | number | null | undefined>) {
|
|
||||||
const template = messages[localeState.value][key] ?? key
|
|
||||||
return interpolate(template, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(value?: string | null) {
|
|
||||||
if (!value) {
|
|
||||||
return t('common.unavailable')
|
|
||||||
}
|
|
||||||
return new Date(value).toLocaleString(localeState.value, { hour12: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLocale(locale: Locale) {
|
|
||||||
const next = normalizeLocale(locale)
|
|
||||||
if (localeState.value === next) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
localeState.value = next
|
|
||||||
storeLocale(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleLocale() {
|
|
||||||
setLocale(localeState.value === 'zh-CN' ? 'en-US' : 'zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLocale() {
|
|
||||||
return {
|
|
||||||
locale: readonly(localeState),
|
|
||||||
setLocale,
|
|
||||||
toggleLocale,
|
|
||||||
t,
|
|
||||||
formatDateTime,
|
|
||||||
nextLocaleLabel: computed(() => t('app.localeToggle')),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ export interface GpsTelemetry {
|
|||||||
utc_time: string
|
utc_time: string
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
raw_latitude_hex?: string
|
|
||||||
raw_longitude_hex?: string
|
|
||||||
satellites: number | null
|
satellites: number | null
|
||||||
altitude_m: number | null
|
altitude_m: number | null
|
||||||
coordinate_system: string
|
coordinate_system: string
|
||||||
@@ -14,236 +12,18 @@ export interface GpsTelemetry {
|
|||||||
updated_at: string
|
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
|
|
||||||
min_srtt_ms?: number
|
|
||||||
srttvar_ms?: number
|
|
||||||
last_feedback_age_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 ControlAckReceiverStatus {
|
|
||||||
backend_ready: boolean
|
|
||||||
started: boolean
|
|
||||||
connected: boolean
|
|
||||||
peer_id: string
|
|
||||||
expected_sender: string
|
|
||||||
reconnect_count: number
|
|
||||||
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 RobotHealthStatus {
|
|
||||||
fault_reason: string
|
|
||||||
recovery_state: string
|
|
||||||
confidence: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoFreshnessStatus {
|
|
||||||
inter_frame_avg_ms: number | null
|
|
||||||
inter_frame_p95_ms: number | null
|
|
||||||
repeated_frame_ratio: number
|
|
||||||
skip_ratio: number
|
|
||||||
longest_freeze_ms: number
|
|
||||||
stale_frame_run_length: number
|
|
||||||
relative_freshness_lag_frames: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LatencyEstimateStatus {
|
|
||||||
control_loop_rtt_ms: number | null
|
|
||||||
control_to_persist_est_ms: number | null
|
|
||||||
control_oneway_srtt_est_ms: number | null
|
|
||||||
control_oneway_bestcase_est_ms: number | null
|
|
||||||
video_network_oneway_est_ms: number | null
|
|
||||||
video_partial_est_ms: number | null
|
|
||||||
video_e2e_est_ms: number | null
|
|
||||||
estimate_method: {
|
|
||||||
control: string
|
|
||||||
video: string
|
|
||||||
}
|
|
||||||
clock_sync_required: boolean
|
|
||||||
assumptions: string[]
|
|
||||||
confidence: {
|
|
||||||
control: string
|
|
||||||
video: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControlAckStatus {
|
|
||||||
ack_available: boolean
|
|
||||||
updated_at: string | null
|
|
||||||
control_loop_rtt_ms: number | null
|
|
||||||
b_recv_to_persist_ms: number | null
|
|
||||||
control_oneway_network_est_ms: number | null
|
|
||||||
control_to_persist_est_ms: number | null
|
|
||||||
sample_reason: string | null
|
|
||||||
receiver: ControlAckReceiverStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkTelemetry {
|
export interface NetworkTelemetry {
|
||||||
peer_status: string
|
peer_status: string
|
||||||
latency_ms: number | null
|
latency_ms: number
|
||||||
jitter_ms: number | null
|
jitter_ms: number
|
||||||
packet_loss_pct: number | null
|
retrans_pct: number
|
||||||
|
packet_loss_pct?: number
|
||||||
tx_kbps: number
|
tx_kbps: number
|
||||||
rx_kbps: number
|
rx_kbps: number
|
||||||
|
signal_dbm: number | null
|
||||||
transport: string
|
transport: string
|
||||||
source_mode: string
|
source_mode: string
|
||||||
updated_at: 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
|
|
||||||
}
|
|
||||||
latency_estimate: LatencyEstimateStatus
|
|
||||||
video_freshness: VideoFreshnessStatus
|
|
||||||
control_ack_status: ControlAckStatus
|
|
||||||
telemetry_receiver: TelemetryReceiverStatus
|
|
||||||
robot_health: RobotHealthStatus
|
|
||||||
ingress: {
|
|
||||||
native_udp: NativeUdpIngress
|
|
||||||
}
|
|
||||||
control: {
|
|
||||||
arbiter: ControlArbiterStatus
|
|
||||||
sender: ControlSenderStatus
|
|
||||||
ack_receiver: ControlAckReceiverStatus
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoStatus {
|
export interface VideoStatus {
|
||||||
@@ -253,67 +33,19 @@ export interface VideoStatus {
|
|||||||
fps: number
|
fps: number
|
||||||
frame_dir: string
|
frame_dir: string
|
||||||
source_detail?: string
|
source_detail?: string
|
||||||
timing?: {
|
|
||||||
available: boolean
|
|
||||||
sender_clock_delta_ms_raw: number | null
|
|
||||||
sender_clock_delta_samples_ms_raw: number[]
|
|
||||||
sample_count: number
|
|
||||||
sample_window_size: number
|
|
||||||
timestamp_unit: string | null
|
|
||||||
timestamp_endianness: string | null
|
|
||||||
unsynced_clock: boolean
|
|
||||||
}
|
|
||||||
freshness?: VideoFreshnessStatus
|
|
||||||
display_probe?: {
|
|
||||||
updated_at: string | null
|
|
||||||
frame_seq: number | null
|
|
||||||
frame_hash: string
|
|
||||||
input_to_next_fresh_frame_ms: number | null
|
|
||||||
input_to_next_changed_frame_ms: number | null
|
|
||||||
input_to_next_paint_ms: number | null
|
|
||||||
request_to_paint_ms: number | null
|
|
||||||
response_to_paint_ms: number | null
|
|
||||||
backend_to_request_ms: number | null
|
|
||||||
backend_to_request_ms_raw: number | null
|
|
||||||
backend_to_paint_ms: number | null
|
|
||||||
backend_to_paint_ms_raw: number | null
|
|
||||||
browser_backend_clock_offset_ms: number | null
|
|
||||||
browser_backend_clock_rtt_ms: number | null
|
|
||||||
browser_backend_clock_sample_count: number
|
|
||||||
browser_backend_clock_calibrated_at: string | null
|
|
||||||
}
|
|
||||||
receiver?: {
|
receiver?: {
|
||||||
backend_ready: boolean
|
backend_ready: boolean
|
||||||
mode: string
|
mode: string
|
||||||
connected: boolean
|
connected: boolean
|
||||||
registered: boolean
|
|
||||||
has_recent_frame: boolean
|
has_recent_frame: boolean
|
||||||
frames_received: number
|
frames_received: number
|
||||||
latest_sequence: number | null
|
latest_sequence: number | null
|
||||||
latest_frame_hash?: string
|
|
||||||
latest_backend_received_unix_ns?: number | null
|
|
||||||
latest_backend_received_mono_ns?: number | null
|
|
||||||
latest_frame_bytes?: number
|
|
||||||
latest_capture_to_send_ms?: number | null
|
|
||||||
reconnect_count: number
|
|
||||||
last_server_error: string
|
|
||||||
last_error: string
|
last_error: string
|
||||||
config_path: string
|
config_path: string
|
||||||
server_addr?: string
|
server_addr?: string
|
||||||
relay_via?: string
|
relay_via?: string
|
||||||
peer_id?: string
|
peer_id?: string
|
||||||
buffer_bytes?: number
|
buffer_bytes?: number
|
||||||
timing?: {
|
|
||||||
available: boolean
|
|
||||||
sender_clock_delta_ms_raw: number | null
|
|
||||||
sender_clock_delta_samples_ms_raw: number[]
|
|
||||||
sample_count: number
|
|
||||||
sample_window_size: number
|
|
||||||
timestamp_unit: string | null
|
|
||||||
timestamp_endianness: string | null
|
|
||||||
unsynced_clock: boolean
|
|
||||||
}
|
|
||||||
freshness?: VideoFreshnessStatus
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ControlPanel from '@/components/ControlPanel.vue'
|
|
||||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||||
import VideoPanel from '@/components/VideoPanel.vue'
|
import VideoPanel from '@/components/VideoPanel.vue'
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||||
@@ -13,10 +11,13 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
|||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('dashboard.eyebrow') }}</p>
|
<p class="eyebrow">Overview</p>
|
||||||
<h1>{{ t('dashboard.title') }}</h1>
|
<h1>机器人竞赛指挥台</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="hero-text">{{ t('dashboard.description') }}</p>
|
<p class="hero-text">
|
||||||
|
当前版本已经接通三块核心能力:JPEG 视频流、GPS 地图定位、网络状态展示。后面接真实
|
||||||
|
C 数据源时,前端页面不需要大改。
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
@@ -24,11 +25,7 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<section class="primary-grid">
|
<VideoPanel :video="video" />
|
||||||
<VideoPanel :video="video" :network="network" />
|
|
||||||
<ControlPanel />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<GpsMapPanel :gps="gps" />
|
<GpsMapPanel :gps="gps" />
|
||||||
<NetworkPanel :network="network" />
|
<NetworkPanel :network="network" />
|
||||||
</main>
|
</main>
|
||||||
@@ -88,19 +85,6 @@ h1 {
|
|||||||
gap: 20px;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.hero {
|
.hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { gps, errorMessage, headerStatus } = useMonitoringData({
|
const { gps, errorMessage, headerStatus } = useMonitoringData({
|
||||||
@@ -12,10 +11,13 @@ const { gps, errorMessage, headerStatus } = useMonitoringData({
|
|||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('mapView.eyebrow') }}</p>
|
<p class="eyebrow">Map</p>
|
||||||
<h1>{{ t('mapView.title') }}</h1>
|
<h1>地图定位页面</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="description">{{ t('mapView.description') }}</p>
|
<p class="description">
|
||||||
|
这里整合了 `GeoStream` 的 GPS 展示逻辑。只要原来的 GPS 模块继续写
|
||||||
|
`gps_latest.json`,这个页面就能直接显示实时定位。
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { network, errorMessage, headerStatus } = useMonitoringData({
|
const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||||
refreshIntervalMs: 500,
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('networkView.eyebrow') }}</p>
|
<p class="eyebrow">Network</p>
|
||||||
<h1>{{ t('networkView.title') }}</h1>
|
<h1>网络状态页面</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="description">{{ t('networkView.description') }}</p>
|
<p class="description">
|
||||||
|
当前先展示模拟网络遥测数据,后续只需要把后端采集函数替换成真实 C 输出,就能保留同样的渲染界面。
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
@@ -70,3 +69,4 @@ h1 {
|
|||||||
border-color: rgba(255, 107, 107, 0.28);
|
border-color: rgba(255, 107, 107, 0.28);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import VideoPanel from '@/components/VideoPanel.vue'
|
import VideoPanel from '@/components/VideoPanel.vue'
|
||||||
import { t } from '@/lib/locale'
|
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { video, network, errorMessage, headerStatus } = useMonitoringData()
|
const { video, errorMessage, headerStatus } = useMonitoringData()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ t('videoView.eyebrow') }}</p>
|
<p class="eyebrow">Video</p>
|
||||||
<h1>{{ t('videoView.title') }}</h1>
|
<h1>视频流页面</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="description">{{ t('videoView.description') }}</p>
|
<p class="description">这个页面专门用于看逐帧 JPEG 画面。前端会按固定频率请求单张 JPEG,后端每次返回一帧。</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
{{ headerStatus }}
|
{{ headerStatus }}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<VideoPanel :video="video" :network="network" />
|
<VideoPanel :video="video" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user