Compare commits

3 Commits
main ... dev

Author SHA1 Message Date
f3bb7eaae4 fix: 前端显示真实发送频率和渲染频率 2026-04-02 22:48:52 +08:00
f6d33d6b56 fix: 不能“sender 里先按 fps 睡再去 DQBUF” 2026-04-02 22:31:40 +08:00
77681329dc fix: requirements.txt 2026-04-02 00:30:58 +08:00
35 changed files with 608 additions and 6598 deletions

0
.codex
View File

View File

@@ -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,
),
})

View File

@@ -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',
]

View File

@@ -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')),
] ]

View File

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

6
backend/control/apps.py Normal file
View File

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

View 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
View 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
View 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,9 +0,0 @@
from django.urls import re_path
from .consumers import ControlConsumer
websocket_urlpatterns = [
re_path(r"^ws/control/$", ControlConsumer.as_asgi()),
]

View File

@@ -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()

View File

@@ -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)

View File

@@ -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"),
] ]

View File

@@ -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)

View File

@@ -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
View File

@@ -0,0 +1,4 @@
Django>=5,<6
djangorestframework>=3.15,<4
django-cors-headers>=4,<5
channels>=4,<5

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
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')
}
</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;
} }
} }

View File

@@ -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 return `发送端 Profile: ${props.video.fps} FPS`
freshResolved: boolean
changedResolved: boolean
paintResolved: boolean
}
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() { const placeholderText = computed(() => {
if (statusTimer != null) { if (!props.video) {
window.clearInterval(statusTimer) return '正在获取视频状态...'
statusTimer = null
} }
void refreshStatus() return '尚未收到实时视频帧'
statusTimer = window.setInterval(() => { })
void refreshStatus()
}, STATUS_REFRESH_MS)
}
function startClockCalibrationLoop() { function refreshStreamUrl() {
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 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
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;
} }
} }

View File

@@ -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,
}
}

View File

@@ -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(() => {

View File

@@ -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()
} }

View File

@@ -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')),
}
}

View File

@@ -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
} }
} }

View File

@@ -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;

View File

@@ -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 }">

View File

@@ -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>

View File

@@ -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>