Compare commits

..

3 Commits

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
28 changed files with 461 additions and 4129 deletions

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 = [
@@ -93,16 +88,3 @@ STATIC_URL = 'static/'
CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_ALL_ORIGINS = True
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,206 +0,0 @@
from __future__ import annotations
import os
import struct
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
WORKSPACE_ROOT = PROJECT_ROOT.parent
JPEG_FRAME_DIR = WORKSPACE_ROOT / "RobotDataShow" / "jpeg-frames"
GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
GEOSTREAM_STALE_SECONDS = 15
OMNISOCKET_CONFIG_PATH = PROJECT_ROOT / "config" / "omnisocket_demo.yaml"
VIDEO_SOURCE_MODE = os.getenv("VIDEO_SOURCE_MODE", "auto").strip().lower()
OMNISOCKET_FRAME_FRESH_SECONDS = 2.0
VIDEO_TIMESTAMP_SAMPLE_SIZE = 10
VIDEO_TIMESTAMP_TRAILER_BYTES = 8
VIDEO_TIMESTAMP_ENDIANNESS = "little"
VIDEO_TIMESTAMP_UNIT = "ms"
VIDEO_TIMESTAMP_MULTIPLIER_NS = 1_000_000
VIDEO_TIMESTAMP_MAX_SKEW_NS = 7 * 24 * 60 * 60 * 1_000_000_000
CONTROL_PACKET = struct.Struct("<6f")
CONTROL_PACKET_SIZE = CONTROL_PACKET.size
CONTROL_SOURCE_NATIVE_UDP = "native_udp"
CONTROL_SOURCE_WEB = "web"
CONTROL_SOURCE_PRIORITY = (CONTROL_SOURCE_NATIVE_UDP, CONTROL_SOURCE_WEB)
ZERO_CONTROL_PAYLOAD = CONTROL_PACKET.pack(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
def utc_iso_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def parse_simple_yaml_scalar(value: str) -> Any:
if value in {'""', "''"}:
return ""
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
if value.lower() == "true":
return True
if value.lower() == "false":
return False
if value and value.lstrip("-").isdigit():
return int(value)
return value
def load_simple_yaml_config(path: Path) -> dict[str, Any]:
parsed: dict[str, Any] = {}
current_section: str | None = None
with path.open("r", encoding="utf-8") as file:
for raw_line in file:
line = raw_line.split("#", 1)[0].rstrip()
if not line.strip():
continue
if not line.startswith(" "):
if not line.endswith(":"):
raise ValueError(f"invalid top-level yaml line: {raw_line.strip()}")
current_section = line[:-1].strip()
parsed[current_section] = {}
continue
if current_section is None:
raise ValueError(f"yaml key outside section: {raw_line.strip()}")
stripped = line.strip()
if ":" not in stripped:
raise ValueError(f"invalid yaml key line: {raw_line.strip()}")
key, value = stripped.split(":", 1)
parsed[current_section][key.strip()] = parse_simple_yaml_scalar(value.strip())
return parsed
def load_omnisocket_config() -> dict[str, Any]:
config: dict[str, Any] = {}
if OMNISOCKET_CONFIG_PATH.exists():
try:
try:
import yaml # type: ignore
with OMNISOCKET_CONFIG_PATH.open("r", encoding="utf-8") as file:
config = yaml.safe_load(file) or {}
except ImportError:
config = load_simple_yaml_config(OMNISOCKET_CONFIG_PATH)
except Exception:
config = {}
transport_cfg = dict(config.get("transport", {}))
video_receiver_cfg = dict(config.get("video_receiver", {}))
control_sender_cfg = dict(config.get("control_sender", {}))
control_ingress_cfg = dict(config.get("control_ingress", {}))
video_sender_cfg = dict(config.get("video_sender", {}))
telemetry_receiver_cfg = dict(config.get("telemetry_receiver", {}))
transport_cfg["server_addr"] = os.getenv(
"OMNISOCKET_SERVER_ADDR",
str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
)
transport_cfg["relay_via"] = os.getenv(
"OMNISOCKET_RELAY_VIA",
str(transport_cfg.get("relay_via", "")),
)
transport_cfg["bind_ip"] = os.getenv(
"OMNISOCKET_BIND_IP",
str(transport_cfg.get("bind_ip", "")),
)
transport_cfg["bind_device"] = os.getenv(
"OMNISOCKET_BIND_DEVICE",
str(transport_cfg.get("bind_device", "")),
)
video_receiver_cfg["peer_id"] = os.getenv(
"OMNISOCKET_VIDEO_PEER_ID",
str(video_receiver_cfg.get("peer_id", "peer-a-video")),
)
video_receiver_cfg["buffer_bytes"] = int(
os.getenv(
"OMNISOCKET_BUFFER_BYTES",
str(video_receiver_cfg.get("buffer_bytes", 1024 * 1024)),
)
)
control_sender_cfg["peer_id"] = os.getenv(
"OMNISOCKET_CONTROL_PEER_ID",
str(control_sender_cfg.get("peer_id", "peer-a-ctrl")),
)
control_sender_cfg["target_peer"] = os.getenv(
"OMNISOCKET_CONTROL_TARGET_PEER",
str(control_sender_cfg.get("target_peer", "peer-b-ctrl")),
)
video_sender_cfg["peer_id"] = os.getenv(
"OMNISOCKET_VIDEO_SENDER_PEER_ID",
str(video_sender_cfg.get("peer_id", "peer-b-video")),
)
video_sender_cfg["target_peer"] = os.getenv(
"OMNISOCKET_VIDEO_TARGET_PEER_ID",
str(video_sender_cfg.get("target_peer", "peer-a-video")),
)
control_ingress_cfg["native_udp_bind"] = os.getenv(
"OMNISOCKET_CONTROL_NATIVE_UDP_BIND",
str(control_ingress_cfg.get("native_udp_bind", "127.0.0.1:10921")),
)
control_ingress_cfg["source_lease_ms"] = int(
os.getenv(
"OMNISOCKET_CONTROL_SOURCE_LEASE_MS",
str(control_ingress_cfg.get("source_lease_ms", 300)),
)
)
control_ingress_cfg["send_rate_hz"] = float(
os.getenv(
"OMNISOCKET_CONTROL_SEND_RATE_HZ",
str(control_ingress_cfg.get("send_rate_hz", 20.0)),
)
)
control_ingress_cfg["zero_burst_packets"] = int(
os.getenv(
"OMNISOCKET_CONTROL_ZERO_BURST_PACKETS",
str(control_ingress_cfg.get("zero_burst_packets", 3)),
)
)
telemetry_receiver_cfg["peer_id"] = os.getenv(
"OMNISOCKET_TELEMETRY_PEER_ID",
str(telemetry_receiver_cfg.get("peer_id", "peer-a-telemetry")),
)
telemetry_receiver_cfg["interval_ms"] = int(
os.getenv(
"OMNISOCKET_TELEMETRY_INTERVAL_MS",
str(telemetry_receiver_cfg.get("interval_ms", 500)),
)
)
telemetry_receiver_cfg["stale_after_ms"] = int(
os.getenv(
"OMNISOCKET_TELEMETRY_STALE_AFTER_MS",
str(telemetry_receiver_cfg.get("stale_after_ms", telemetry_receiver_cfg["interval_ms"] * 3)),
)
)
return {
"transport": transport_cfg,
"video_receiver": video_receiver_cfg,
"control_sender": control_sender_cfg,
"control_ingress": control_ingress_cfg,
"video_sender": video_sender_cfg,
"telemetry_receiver": telemetry_receiver_cfg,
}
def parse_host_port(bind_addr: str) -> tuple[str, int]:
host, port_text = bind_addr.rsplit(":", 1)
host = host.strip() or "127.0.0.1"
port = int(port_text)
if port <= 0 or port > 65535:
raise ValueError(f"invalid port in bind address: {bind_addr}")
return host, port

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,459 +0,0 @@
from __future__ import annotations
import socket
import sys
import threading
import time
from typing import Any
from .common import (
CONTROL_PACKET_SIZE,
CONTROL_SOURCE_NATIVE_UDP,
CONTROL_SOURCE_PRIORITY,
ZERO_CONTROL_PAYLOAD,
WORKSPACE_ROOT,
load_omnisocket_config,
parse_host_port,
)
from .video import safe_kcp_stats
class OmniSocketControlSender:
def __init__(self) -> None:
self._lock = threading.Lock()
self._session = None
self._session_cls = None
self._msg_type_error = None
self._control_defaults: dict[str, Any] = {}
self._started = False
self._drain_thread: threading.Thread | None = None
self._closing = threading.Event()
self._target_peer = ""
self._send_count = 0
self._send_errors = 0
self._drain_errors = 0
self._last_error = ""
self._reconnect_count = 0
self._ever_connected = False
self._registered = False
self._load_backend()
def _load_backend(self) -> None:
try:
self._import_backend()
except Exception as error: # pragma: no cover - optional runtime dependency
self._last_error = f"omnisocket import failed: {error}"
def _import_backend(self) -> None:
try:
from omnisocket import CONTROL_DEFAULTS, MSG_TYPE_ERROR, Session # type: ignore
except ImportError:
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
if python_dir.exists():
sys.path.insert(0, str(python_dir))
from omnisocket import CONTROL_DEFAULTS, MSG_TYPE_ERROR, Session # type: ignore
self._session_cls = Session
self._msg_type_error = MSG_TYPE_ERROR
self._control_defaults = dict(CONTROL_DEFAULTS)
def _connect_session(self):
assert self._session_cls is not None
config = load_omnisocket_config()
transport_cfg = config.get("transport", {})
control_cfg = config.get("control_sender", {})
session = self._session_cls()
session.connect(
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
peer_id=str(control_cfg.get("peer_id", "peer-a-ctrl")),
relay_via=str(transport_cfg.get("relay_via", "")),
bind_ip=str(transport_cfg.get("bind_ip", "")),
bind_device=str(transport_cfg.get("bind_device", "")),
**self._control_defaults,
)
target_peer = str(control_cfg.get("target_peer", "peer-b-ctrl"))
return session, target_peer
def ensure_started(self) -> None:
if self._session_cls is None:
return
with self._lock:
if self._closing.is_set():
return
if self._started and self._session is not None:
return
session, target_peer = self._connect_session()
self._session = session
self._target_peer = target_peer
self._closing.clear()
self._started = True
self._last_error = ""
self._registered = bool(dict(session.stats()).get("registered", 0))
if self._ever_connected:
self._reconnect_count += 1
else:
self._ever_connected = True
self._drain_thread = threading.Thread(
target=self._drain_loop,
name="omnisocket-control-drain",
daemon=True,
)
self._drain_thread.start()
def _reset_session(self, session: Any | None) -> None:
with self._lock:
if session is not None and session is not self._session:
return
current = self._session
self._session = None
self._started = False
self._registered = False
if current is not None:
try:
current.close()
except Exception:
pass
def send_payload(self, payload: bytes) -> None:
if len(payload) != CONTROL_PACKET_SIZE:
raise ValueError(f"expected {CONTROL_PACKET_SIZE} bytes, got {len(payload)}")
self.ensure_started()
with self._lock:
session = self._session
target_peer = self._target_peer
if session is None:
raise RuntimeError("control session is not available")
try:
session.send(to=target_peer, data=payload)
except Exception as error:
with self._lock:
self._send_errors += 1
self._last_error = str(error)
self._reset_session(session)
raise
with self._lock:
self._send_count += 1
def send_zero_burst(self, count: int) -> None:
for _ in range(max(0, count)):
try:
self.send_payload(ZERO_CONTROL_PAYLOAD)
except Exception:
return
def _drain_loop(self) -> None:
while not self._closing.is_set():
with self._lock:
session = self._session
if session is None:
return
try:
result = session.recv(timeout_ms=100)
except Exception as error:
last_server_error = ""
try:
last_server_error = str(dict(session.stats()).get("last_server_error", "") or "")
except Exception:
last_server_error = ""
with self._lock:
self._drain_errors += 1
self._registered = False
self._last_error = last_server_error or str(error)
if not self._closing.is_set():
self._reset_session(session)
return
if result is None:
try:
stats = dict(session.stats())
except Exception:
stats = {}
with self._lock:
self._registered = bool(stats.get("registered", 0))
if stats.get("last_server_error"):
self._last_error = str(stats.get("last_server_error"))
continue
from_peer, msg_type, payload = result
if msg_type == self._msg_type_error:
text = payload.decode("utf-8", errors="replace")
try:
stats = dict(session.stats())
except Exception:
stats = {}
with self._lock:
self._last_error = f"server error from {from_peer}: {text}"
self._registered = bool(stats.get("registered", 0))
def session_stats(self) -> dict[str, Any]:
with self._lock:
session = self._session
if session is None:
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
try:
return dict(session.stats())
except Exception:
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
def session_kcp_stats(self) -> dict[str, Any]:
with self._lock:
session = self._session
return safe_kcp_stats(session)
def get_status(self) -> dict[str, Any]:
config = load_omnisocket_config()
control_cfg = config.get("control_sender", {})
session_stats = self.session_stats()
with self._lock:
return {
"backend_ready": self._session_cls is not None,
"started": self._started,
"connected": self._session is not None,
"registered": bool(session_stats.get("registered", 0)),
"peer_id": str(control_cfg.get("peer_id", "")),
"target_peer": str(control_cfg.get("target_peer", "")),
"send_count": self._send_count,
"send_errors": self._send_errors,
"drain_errors": self._drain_errors,
"reconnect_count": self._reconnect_count,
"last_server_error": str(session_stats.get("last_server_error", "") or ""),
"last_error": self._last_error,
}
def close(self) -> None:
self._closing.set()
self.send_zero_burst(1)
self._reset_session(None)
drain_thread = self._drain_thread
if drain_thread is not None and drain_thread.is_alive():
drain_thread.join(timeout=0.5)
class ControlArbiter:
def __init__(self, sender: OmniSocketControlSender) -> None:
self._sender = sender
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._closing = threading.Event()
self._started = False
self._source_lease_ms = 300
self._send_rate_hz = 20.0
self._zero_burst_packets = 3
self._latest_by_source: dict[str, tuple[bytes, float]] = {}
self._packet_counts = {source: 0 for source in CONTROL_SOURCE_PRIORITY}
self._last_payload = ZERO_CONTROL_PAYLOAD
self._last_sent_at = 0.0
self._active_source: str | None = None
self._last_error = ""
def _load_config(self) -> None:
cfg = load_omnisocket_config().get("control_ingress", {})
self._source_lease_ms = max(50, int(cfg.get("source_lease_ms", 300)))
self._send_rate_hz = max(1.0, float(cfg.get("send_rate_hz", 20.0)))
self._zero_burst_packets = max(1, int(cfg.get("zero_burst_packets", 3)))
def ensure_started(self) -> None:
self._load_config()
with self._lock:
if self._closing.is_set():
return
if self._started:
return
self._started = True
self._thread = threading.Thread(
target=self._send_loop,
name="control-arbiter",
daemon=True,
)
self._thread.start()
def ingest_command(self, source: str, payload: bytes) -> None:
if source not in CONTROL_SOURCE_PRIORITY:
raise ValueError(f"unsupported control source: {source}")
if len(payload) != CONTROL_PACKET_SIZE:
raise ValueError(f"expected {CONTROL_PACKET_SIZE} bytes, got {len(payload)}")
self.ensure_started()
now = time.monotonic()
with self._lock:
self._latest_by_source[source] = (payload, now)
self._packet_counts[source] += 1
def _resolve_active_locked(self, now: float) -> tuple[str | None, bytes, int]:
lease_seconds = self._source_lease_ms / 1000.0
expired_sources = [
source
for source, (_, updated_at) in self._latest_by_source.items()
if (now - updated_at) > lease_seconds
]
for source in expired_sources:
self._latest_by_source.pop(source, None)
for source in CONTROL_SOURCE_PRIORITY:
entry = self._latest_by_source.get(source)
if entry is None:
continue
payload, updated_at = entry
remaining_ms = max(0, int((lease_seconds - (now - updated_at)) * 1000))
return source, payload, remaining_ms
return None, ZERO_CONTROL_PAYLOAD, 0
def _send_loop(self) -> None:
interval = 1.0 / max(self._send_rate_hz, 1.0)
previous_active: str | None = None
while not self._closing.is_set():
now = time.monotonic()
with self._lock:
active_source, payload, _lease_ms = self._resolve_active_locked(now)
self._active_source = active_source
self._last_payload = payload
if previous_active is not None and active_source is None:
try:
self._sender.send_zero_burst(self._zero_burst_packets)
except Exception as error:
with self._lock:
self._last_error = str(error)
elif active_source is not None:
try:
self._sender.send_payload(payload)
with self._lock:
self._last_sent_at = time.monotonic()
self._last_error = ""
except Exception as error:
with self._lock:
self._last_error = str(error)
previous_active = active_source
self._closing.wait(interval)
try:
self._sender.send_zero_burst(self._zero_burst_packets)
except Exception:
pass
def get_status(self) -> dict[str, Any]:
self.ensure_started()
now = time.monotonic()
with self._lock:
active_source, _payload, lease_ms = self._resolve_active_locked(now)
return {
"active_source": active_source,
"control_lease_remaining_ms": lease_ms,
"packet_counts": dict(self._packet_counts),
"send_rate_hz": self._send_rate_hz,
"source_lease_ms": self._source_lease_ms,
"zero_burst_packets": self._zero_burst_packets,
"last_error": self._last_error,
"last_sent_at_monotonic": self._last_sent_at,
}
def close(self) -> None:
self._closing.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)
class NativeUdpControlIngress:
def __init__(self, arbiter: ControlArbiter) -> None:
self._arbiter = arbiter
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._closing = threading.Event()
self._started = False
self._bind_addr = "127.0.0.1:10921"
self._packets_received = 0
self._invalid_packets = 0
self._last_sender = ""
self._last_error = ""
def ensure_started(self) -> None:
bind_addr = str(load_omnisocket_config().get("control_ingress", {}).get("native_udp_bind", "127.0.0.1:10921"))
with self._lock:
self._bind_addr = bind_addr
if self._closing.is_set():
return
if self._thread is not None and self._thread.is_alive():
return
self._started = True
self._thread = threading.Thread(
target=self._run,
name="native-udp-control-ingress",
daemon=True,
)
self._thread.start()
def _run(self) -> None:
try:
try:
host, port = parse_host_port(self._bind_addr)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
sock.settimeout(0.1)
except Exception as error:
with self._lock:
self._last_error = str(error)
return
with sock:
while not self._closing.is_set():
try:
payload, sender_addr = sock.recvfrom(CONTROL_PACKET_SIZE + 64)
except socket.timeout:
continue
except OSError as error:
with self._lock:
if not self._closing.is_set():
self._last_error = str(error)
return
with self._lock:
self._last_sender = f"{sender_addr[0]}:{sender_addr[1]}"
if len(payload) != CONTROL_PACKET_SIZE:
with self._lock:
self._invalid_packets += 1
continue
try:
self._arbiter.ingest_command(CONTROL_SOURCE_NATIVE_UDP, payload)
except Exception as error:
with self._lock:
self._last_error = str(error)
continue
with self._lock:
self._packets_received += 1
finally:
with self._lock:
self._started = False
self._thread = None
def get_status(self) -> dict[str, Any]:
self.ensure_started()
with self._lock:
return {
"started": self._started,
"bind_addr": self._bind_addr,
"packets_received": self._packets_received,
"invalid_packets": self._invalid_packets,
"last_sender": self._last_sender,
"last_error": self._last_error,
}
def close(self) -> None:
self._closing.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)

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,44 +1,212 @@
from __future__ import annotations from __future__ import annotations
import atexit import json
import math
from .control import ControlArbiter, NativeUdpControlIngress, OmniSocketControlSender import sys
from .telemetry import GpsDataService, HubTelemetryReceiver, NetworkTelemetryService import time
from .video import OmniSocketVideoReceiver, 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_sender = OmniSocketControlSender() WORKSPACE_ROOT = PROJECT_ROOT.parent
_hub_telemetry_receiver = HubTelemetryReceiver() GEOSTREAM_JSON_PATH = WORKSPACE_ROOT / "GeoStream" / "gps_latest.json"
GEOSTREAM_STALE_SECONDS = 15
control_arbiter = ControlArbiter(_control_sender) DAEMON_FRAME_URI = "omni-daemon://latest-frame"
native_control_ingress = NativeUdpControlIngress(control_arbiter)
video_service = VideoFrameService(_video_receiver)
gps_service = GpsDataService()
network_service = NetworkTelemetryService(
_video_receiver,
_control_sender,
control_arbiter,
native_control_ingress,
_hub_telemetry_receiver,
)
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():
_hub_telemetry_receiver.close, try:
_video_receiver.close, from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
_control_sender.close, except ImportError:
): python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
if python_dir.exists():
sys.path.insert(0, str(python_dir))
from omnisocket_a_side.client import OmniDaemonClient, OmniDaemonError
return OmniDaemonClient, OmniDaemonError
_OmniDaemonClient, OmniDaemonError = _load_daemon_client_api()
_daemon_client = _OmniDaemonClient()
def get_daemon_client():
return _daemon_client
def _default_receiver(error_message: str) -> dict[str, Any]:
return {
"backend_ready": False,
"mode": "daemon",
"connected": False,
"has_recent_frame": False,
"frames_received": 0,
"latest_sequence": None,
"last_error": error_message,
"config_path": "",
"server_addr": "",
"relay_via": "",
"peer_id": "",
"buffer_bytes": 0,
}
class VideoFrameService:
def get_status(self) -> dict[str, Any]:
try: try:
closer() state = get_daemon_client().get_state()
except Exception: except OmniDaemonError as error:
pass 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)
atexit.register(shutdown_monitoring_services) 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,674 +0,0 @@
from __future__ import annotations
from collections import deque
import json
import math
import sys
import threading
import time
from datetime import datetime, timezone
from typing import Any
from .common import (
GEOSTREAM_JSON_PATH,
GEOSTREAM_STALE_SECONDS,
WORKSPACE_ROOT,
load_omnisocket_config,
utc_iso_now,
)
from .control import ControlArbiter, NativeUdpControlIngress, OmniSocketControlSender
from .video import OmniSocketVideoReceiver
LOCAL_SAMPLE_INTERVAL_MS = 500
TREND_HISTORY_SIZE = 10
TREND_WINDOW_SIZE = 5
def _utc_from_epoch(epoch_seconds: float | None) -> str | None:
if epoch_seconds is None or epoch_seconds <= 0.0:
return None
return datetime.fromtimestamp(epoch_seconds, timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def _coerce_int(value: Any, default: int = 0) -> int:
try:
if value is None:
return default
return int(value)
except (TypeError, ValueError):
return default
def _coerce_float(value: Any, default: float = 0.0) -> float:
try:
if value is None:
return default
return float(value)
except (TypeError, ValueError):
return default
class GpsDataService:
def get_latest(self) -> dict[str, Any]:
payload = self._read_geostream_payload()
if payload is not None:
payload["source_mode"] = "geostream-json"
payload["updated_at"] = utc_iso_now()
return payload
return self._build_simulated_payload()
def _read_geostream_payload(self) -> dict[str, Any] | None:
if not GEOSTREAM_JSON_PATH.exists():
return None
age_seconds = time.time() - GEOSTREAM_JSON_PATH.stat().st_mtime
if age_seconds > GEOSTREAM_STALE_SECONDS:
return None
try:
with GEOSTREAM_JSON_PATH.open("r", encoding="utf-8") as file:
return json.load(file)
except (OSError, json.JSONDecodeError):
return None
def _build_simulated_payload(self) -> dict[str, Any]:
tick = time.time() / 12.0
latitude = 31.2304 + math.sin(tick) * 0.0014
longitude = 121.4737 + math.cos(tick) * 0.0018
return {
"has_fix": True,
"utc_time": datetime.now(timezone.utc).strftime("%H:%M:%S"),
"latitude": round(latitude, 6),
"longitude": round(longitude, 6),
"satellites": 14 + int((math.sin(tick * 0.7) + 1.0) * 2),
"altitude_m": round(6.5 + math.cos(tick * 0.5) * 1.2, 2),
"coordinate_system": "WGS84",
"source_sentence": "SIMULATED",
"raw_coordinate_format": "decimal degrees",
"source_mode": "simulated",
"updated_at": utc_iso_now(),
}
class KcpTrendTracker:
def __init__(self) -> None:
self._lock = threading.Lock()
self._samples: dict[str, deque[dict[str, Any]]] = {}
def _normalize(self, stats: dict[str, Any] | None) -> dict[str, Any]:
raw = dict(stats or {})
snd_wnd = _coerce_int(raw.get("snd_wnd"))
rmt_wnd = _coerce_int(raw.get("rmt_wnd"))
inflight = _coerce_int(raw.get("inflight"))
window_limit = _coerce_int(raw.get("window_limit"), min(snd_wnd, rmt_wnd) if snd_wnd and rmt_wnd else 0)
return {
"connected": _coerce_int(raw.get("connected")),
"conv": _coerce_int(raw.get("conv")),
"rto_ms": _coerce_int(raw.get("rto_ms")),
"srtt_ms": _coerce_int(raw.get("srtt_ms")),
"srttvar_ms": _coerce_int(raw.get("srttvar_ms")),
"snd_wnd": snd_wnd,
"rmt_wnd": rmt_wnd,
"inflight": inflight,
"window_limit": window_limit,
"window_pressure_pct": round(_coerce_float(raw.get("window_pressure_pct")), 3),
"snd_queue": _coerce_int(raw.get("snd_queue")),
"rcv_queue": _coerce_int(raw.get("rcv_queue")),
"snd_buffer": _coerce_int(raw.get("snd_buffer")),
"out_segs_total": _coerce_int(raw.get("out_segs_total")),
"retrans_total": _coerce_int(raw.get("retrans_total")),
"fast_retrans_total": _coerce_int(raw.get("fast_retrans_total")),
"lost_total": _coerce_int(raw.get("lost_total")),
"repeat_total": _coerce_int(raw.get("repeat_total")),
"xmit_total": _coerce_int(raw.get("xmit_total")),
}
def add_sample(self, key: str, stats: dict[str, Any] | None) -> None:
sample = {
"ts_monotonic": time.monotonic(),
"updated_at": utc_iso_now(),
"stats": self._normalize(stats),
}
with self._lock:
history = self._samples.setdefault(key, deque(maxlen=TREND_HISTORY_SIZE))
history.append(sample)
def latest_updated_at(self, key: str) -> str | None:
with self._lock:
history = self._samples.get(key)
if not history:
return None
return str(history[-1].get("updated_at") or "")
def describe(self, key: str, current_stats: dict[str, Any] | None) -> dict[str, Any]:
current = self._normalize(current_stats)
with self._lock:
history = list(self._samples.get(key, ()))
timeline = history + [{"stats": current, "updated_at": utc_iso_now()}]
previous = timeline[-2]["stats"] if len(timeline) >= 2 else None
trend_window = [entry["stats"] for entry in timeline[-TREND_WINDOW_SIZE:]]
deadband = max(2.0, 0.05 * float(max(current.get("window_limit", 0), 1)))
snd_queue_delta = 0
snd_buffer_delta = 0
retrans_delta = 0
fast_retrans_delta = 0
lost_delta = 0
repeat_delta = 0
out_segs_delta = 0
if previous is not None:
snd_queue_delta = max(0, current["snd_queue"] - _coerce_int(previous.get("snd_queue")))
snd_buffer_delta = max(0, current["snd_buffer"] - _coerce_int(previous.get("snd_buffer")))
retrans_delta = max(0, current["retrans_total"] - _coerce_int(previous.get("retrans_total")))
fast_retrans_delta = max(0, current["fast_retrans_total"] - _coerce_int(previous.get("fast_retrans_total")))
lost_delta = max(0, current["lost_total"] - _coerce_int(previous.get("lost_total")))
repeat_delta = max(0, current["repeat_total"] - _coerce_int(previous.get("repeat_total")))
out_segs_delta = max(0, current["out_segs_total"] - _coerce_int(previous.get("out_segs_total")))
def classify(field: str) -> str:
if len(trend_window) < 2:
return "stable"
oldest = float(_coerce_int(trend_window[0].get(field)))
newest = float(_coerce_int(trend_window[-1].get(field)))
delta = newest - oldest
if abs(delta) < deadband:
return "stable"
return "rising" if delta > 0 else "falling"
repair_rate_pct = 0.0
if out_segs_delta > 0:
repair_rate_pct = round((retrans_delta / out_segs_delta) * 100.0, 3)
return {
"kcp": current,
"trend": {
"snd_queue_delta": snd_queue_delta,
"snd_buffer_delta": snd_buffer_delta,
"snd_queue_trend": classify("snd_queue"),
"snd_buffer_trend": classify("snd_buffer"),
"retrans_delta": retrans_delta,
"fast_retrans_delta": fast_retrans_delta,
"lost_delta": lost_delta,
"repeat_delta": repeat_delta,
"out_segs_delta": out_segs_delta,
"repair_rate_pct": repair_rate_pct,
},
}
class HubTelemetryReceiver:
def __init__(self) -> None:
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._started = False
self._session = None
self._session_cls = None
self._msg_type_text = None
self._msg_type_error = None
self._telemetry_defaults: dict[str, Any] = {}
self._latest_snapshot: dict[str, Any] | None = None
self._last_error = ""
self._last_received_wall = 0.0
self._last_received_monotonic = 0.0
self._reconnect_count = 0
self._ever_connected = False
self._closing = threading.Event()
self._load_backend()
def _load_backend(self) -> None:
try:
self._import_backend()
except Exception as error: # pragma: no cover - optional runtime dependency
self._last_error = f"omnisocket import failed: {error}"
def _import_backend(self) -> None:
try:
from omnisocket import MSG_TYPE_ERROR, MSG_TYPE_TEXT, Session, TELEMETRY_DEFAULTS # type: ignore
except ImportError:
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
if python_dir.exists():
sys.path.insert(0, str(python_dir))
from omnisocket import MSG_TYPE_ERROR, MSG_TYPE_TEXT, Session, TELEMETRY_DEFAULTS # type: ignore
self._msg_type_error = MSG_TYPE_ERROR
self._msg_type_text = MSG_TYPE_TEXT
self._session_cls = Session
self._telemetry_defaults = dict(TELEMETRY_DEFAULTS)
def _connect_session(self):
assert self._session_cls is not None
config = load_omnisocket_config()
transport_cfg = config.get("transport", {})
telemetry_cfg = config.get("telemetry_receiver", {})
session = self._session_cls()
session.connect(
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
peer_id=str(telemetry_cfg.get("peer_id", "peer-a-telemetry")),
relay_via=str(transport_cfg.get("relay_via", "")),
bind_ip=str(transport_cfg.get("bind_ip", "")),
bind_device=str(transport_cfg.get("bind_device", "")),
**self._telemetry_defaults,
)
return session
def ensure_started(self) -> None:
if self._session_cls is None:
return
with self._lock:
if self._started or self._closing.is_set():
return
self._started = True
self._thread = threading.Thread(
target=self._run,
name="hub-telemetry-receiver",
daemon=True,
)
self._thread.start()
def _run(self) -> None:
while not self._closing.is_set():
try:
session = self._connect_session()
with self._lock:
self._session = session
self._last_error = ""
if self._ever_connected:
self._reconnect_count += 1
else:
self._ever_connected = True
while not self._closing.is_set():
result = session.recv(timeout_ms=1000)
if result is None:
continue
from_peer, msg_type, payload = result
if msg_type == self._msg_type_error:
with self._lock:
self._last_error = f"hub error from {from_peer}: {payload.decode('utf-8', errors='replace')}"
continue
if msg_type != self._msg_type_text:
continue
snapshot = json.loads(payload.decode("utf-8"))
if snapshot.get("type") != "hub_kcp_snapshot":
continue
now_wall = time.time()
now_mono = time.monotonic()
with self._lock:
self._latest_snapshot = snapshot
self._last_received_wall = now_wall
self._last_received_monotonic = now_mono
self._last_error = ""
except Exception as error: # pragma: no cover - runtime integration path
if not self._closing.is_set():
session_error = ""
if self._session is not None:
try:
session_error = str(dict(self._session.stats()).get("last_server_error", "") or "")
except Exception:
session_error = ""
with self._lock:
self._last_error = session_error or str(error)
finally:
with self._lock:
session = self._session
self._session = None
if self._closing.is_set():
self._started = False
if session is not None:
try:
session.close()
except Exception:
pass
if not self._closing.is_set():
time.sleep(2)
def get_snapshot(self) -> dict[str, Any]:
self.ensure_started()
cfg = load_omnisocket_config().get("telemetry_receiver", {})
stale_after_ms = max(500, int(cfg.get("stale_after_ms", 1500)))
with self._lock:
received_monotonic = self._last_received_monotonic
received_wall = self._last_received_wall
snapshot = self._latest_snapshot
connected = self._session is not None
last_error = self._last_error
reconnect_count = self._reconnect_count
if self._session is not None:
try:
session_stats = dict(self._session.stats())
except Exception:
session_stats = {}
else:
session_stats = {}
stale = True
if received_monotonic > 0.0:
stale = (time.monotonic() - received_monotonic) * 1000.0 > stale_after_ms
return {
"connected": connected,
"updated_at": _utc_from_epoch(received_wall),
"received_at_monotonic": received_monotonic,
"stale": stale,
"peer_id": str(cfg.get("peer_id", "peer-a-telemetry")),
"snapshot": snapshot or {"sessions": []},
"last_error": last_error,
"registered": bool(session_stats.get("registered", 0)),
"last_server_error": str(session_stats.get("last_server_error", "") or ""),
"reconnect_count": reconnect_count,
}
def close(self) -> None:
self._closing.set()
with self._lock:
session = self._session
if session is not None:
try:
session.close()
except Exception:
pass
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)
class NetworkTelemetryService:
def __init__(
self,
video_receiver: OmniSocketVideoReceiver,
control_sender: OmniSocketControlSender,
control_arbiter: ControlArbiter,
native_ingress: NativeUdpControlIngress,
hub_receiver: HubTelemetryReceiver,
) -> None:
self._video_receiver = video_receiver
self._control_sender = control_sender
self._control_arbiter = control_arbiter
self._native_ingress = native_ingress
self._hub_receiver = hub_receiver
self._trend_tracker = KcpTrendTracker()
self._rate_lock = threading.Lock()
self._last_rate_sample: tuple[float, int, int] | None = None
self._sample_thread: threading.Thread | None = None
self._sample_started = False
self._last_remote_snapshot_at = 0.0
self._closing = threading.Event()
def _ensure_started(self) -> None:
self._video_receiver.ensure_started()
self._control_arbiter.ensure_started()
self._native_ingress.ensure_started()
self._hub_receiver.ensure_started()
with self._rate_lock:
if self._sample_started or self._closing.is_set():
return
self._sample_started = True
self._sample_thread = threading.Thread(
target=self._sample_loop,
name="network-telemetry-sampler",
daemon=True,
)
self._sample_thread.start()
def _sample_loop(self) -> None:
interval_seconds = LOCAL_SAMPLE_INTERVAL_MS / 1000.0
while not self._closing.is_set():
try:
self._trend_tracker.add_sample("a_to_d.video", self._video_receiver.session_kcp_stats())
self._trend_tracker.add_sample("a_to_d.control", self._control_sender.session_kcp_stats())
except Exception:
pass
time.sleep(interval_seconds)
def _compute_rates(self, send_bytes: int, recv_bytes: int) -> tuple[float, float]:
now = time.monotonic()
with self._rate_lock:
previous = self._last_rate_sample
self._last_rate_sample = (now, send_bytes, recv_bytes)
if previous is None:
return 0.0, 0.0
prev_time, prev_send, prev_recv = previous
elapsed = now - prev_time
if elapsed <= 0.0:
return 0.0, 0.0
tx_kbps = max(0.0, ((send_bytes - prev_send) * 8.0) / elapsed / 1000.0)
rx_kbps = max(0.0, ((recv_bytes - prev_recv) * 8.0) / elapsed / 1000.0)
return tx_kbps, rx_kbps
def _ingest_remote_snapshot(self, telemetry_state: dict[str, Any]) -> None:
received_at = float(telemetry_state.get("received_at_monotonic") or 0.0)
if received_at <= 0.0 or received_at <= self._last_remote_snapshot_at:
return
snapshot = telemetry_state.get("snapshot") or {}
sessions = snapshot.get("sessions") or []
for session in sessions:
peer_id = str(session.get("peer_id", "")).strip()
if not peer_id:
continue
self._trend_tracker.add_sample(f"hub::{peer_id}", session)
self._last_remote_snapshot_at = received_at
def _build_session_payload(
self,
trend_key: str,
peer_id: str,
app_stats: dict[str, Any] | None,
current_kcp: dict[str, Any] | None,
updated_at: str | None,
stale: bool,
) -> dict[str, Any]:
described = self._trend_tracker.describe(trend_key, current_kcp)
connected = bool(described["kcp"].get("connected"))
if app_stats is not None and "registered" in app_stats:
connected = bool(app_stats.get("registered"))
return {
"peer_id": peer_id,
"connected": connected,
"updated_at": updated_at,
"stale": stale,
"app": app_stats,
"kcp": described["kcp"],
"trend": described["trend"],
}
def _build_link(self, source: str, updated_at: str | None, stale: bool, sessions: dict[str, dict[str, Any]]) -> dict[str, Any]:
session_items = list(sessions.values())
active_sessions = [session for session in session_items if session.get("connected") and not session.get("stale")]
retrans_sum = sum(_coerce_int(session.get("trend", {}).get("retrans_delta")) for session in active_sessions)
out_segs_sum = sum(_coerce_int(session.get("trend", {}).get("out_segs_delta")) for session in active_sessions)
repair_rate_pct = round((retrans_sum / out_segs_sum) * 100.0, 3) if out_segs_sum > 0 else 0.0
return {
"source": source,
"updated_at": updated_at,
"stale": stale,
"aggregate": {
"online_sessions": len(active_sessions),
"max_window_pressure_pct": max(
(_coerce_float(session.get("kcp", {}).get("window_pressure_pct")) for session in active_sessions),
default=0.0,
),
"sum_snd_queue": sum(_coerce_int(session.get("kcp", {}).get("snd_queue")) for session in active_sessions),
"sum_snd_buffer": sum(_coerce_int(session.get("kcp", {}).get("snd_buffer")) for session in active_sessions),
"sum_retrans_delta": retrans_sum,
"sum_out_segs_delta": out_segs_sum,
"repair_rate_pct": repair_rate_pct,
},
"sessions": sessions,
}
def _pick_primary_session(self, links: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
candidates = (
links["a_to_d"]["sessions"]["control"],
links["a_to_d"]["sessions"]["video"],
links["d_to_b"]["sessions"]["control"],
links["d_to_b"]["sessions"]["video"],
)
for session in candidates:
if session.get("connected") and not session.get("stale"):
return session
return None
def get_latest(self) -> dict[str, Any]:
self._ensure_started()
config = load_omnisocket_config()
video_receiver_cfg = config.get("video_receiver", {})
control_sender_cfg = config.get("control_sender", {})
video_sender_cfg = config.get("video_sender", {})
video_app = self._video_receiver.session_stats()
control_app = self._control_sender.session_stats()
video_kcp = self._video_receiver.session_kcp_stats()
control_kcp = self._control_sender.session_kcp_stats()
arbiter_status = self._control_arbiter.get_status()
ingress_status = self._native_ingress.get_status()
sender_status = self._control_sender.get_status()
telemetry_state = self._hub_receiver.get_snapshot()
total_send_bytes = int(video_app.get("send_bytes", 0)) + int(control_app.get("send_bytes", 0))
total_recv_bytes = int(video_app.get("recv_bytes", 0)) + int(control_app.get("recv_bytes", 0))
tx_kbps, rx_kbps = self._compute_rates(total_send_bytes, total_recv_bytes)
local_updated_at = utc_iso_now()
local_sessions = {
"video": self._build_session_payload(
"a_to_d.video",
str(video_receiver_cfg.get("peer_id", "peer-a-video")),
video_app,
video_kcp,
local_updated_at,
False,
),
"control": self._build_session_payload(
"a_to_d.control",
str(control_sender_cfg.get("peer_id", "peer-a-ctrl")),
control_app,
control_kcp,
local_updated_at,
False,
),
}
remote_snapshot = telemetry_state.get("snapshot") or {}
remote_sessions_by_peer = {
str(session.get("peer_id", "")).strip(): session
for session in remote_snapshot.get("sessions", []) or []
if str(session.get("peer_id", "")).strip()
}
remote_updated_at = telemetry_state.get("updated_at")
remote_stale = bool(telemetry_state.get("stale", True))
remote_sessions = {
"video": self._build_session_payload(
f"hub::{str(video_sender_cfg.get('peer_id', 'peer-b-video'))}",
str(video_sender_cfg.get("peer_id", "peer-b-video")),
None,
remote_sessions_by_peer.get(str(video_sender_cfg.get("peer_id", "peer-b-video")), {}),
remote_updated_at,
remote_stale,
),
"control": self._build_session_payload(
f"hub::{str(control_sender_cfg.get('target_peer', 'peer-b-ctrl'))}",
str(control_sender_cfg.get("target_peer", "peer-b-ctrl")),
None,
remote_sessions_by_peer.get(str(control_sender_cfg.get("target_peer", "peer-b-ctrl")), {}),
remote_updated_at,
remote_stale,
),
}
links = {
"a_to_d": self._build_link("local-a-side", local_updated_at, False, local_sessions),
"d_to_b": self._build_link("hub-telemetry", remote_updated_at, remote_stale, remote_sessions),
}
primary_session = self._pick_primary_session(links)
primary_kcp = dict(primary_session.get("kcp", {})) if primary_session is not None else {}
self._ingest_remote_snapshot(telemetry_state)
fresh_connected_sessions = (
links["a_to_d"]["aggregate"]["online_sessions"] + links["d_to_b"]["aggregate"]["online_sessions"]
)
latency_ms = primary_kcp.get("srtt_ms") if primary_session is not None else None
jitter_ms = primary_kcp.get("srttvar_ms") if primary_session is not None else None
local_control_registered = bool(control_app.get("registered", 0))
remote_control_fresh = bool(remote_sessions["control"].get("connected")) and not bool(remote_sessions["control"].get("stale"))
if local_control_registered and remote_control_fresh:
peer_status = "online"
elif local_control_registered or bool(local_sessions["video"].get("connected")):
peer_status = "degraded"
elif sender_status.get("backend_ready"):
peer_status = "idle"
else:
peer_status = "backend-unavailable"
return {
"peer_status": peer_status,
"latency_ms": latency_ms,
"jitter_ms": jitter_ms,
"packet_loss_pct": None,
"tx_kbps": round(tx_kbps, 3),
"rx_kbps": round(rx_kbps, 3),
"transport": "OmniSocket / kcp",
"source_mode": "omnisocket-live" if fresh_connected_sessions > 0 else "omnisocket-idle",
"updated_at": utc_iso_now(),
"active_control_source": arbiter_status["active_source"],
"control_lease_remaining_ms": arbiter_status["control_lease_remaining_ms"],
"combined": {
"connected_sessions": fresh_connected_sessions,
"send_bytes": total_send_bytes,
"recv_bytes": total_recv_bytes,
"tx_kbps": round(tx_kbps, 3),
"rx_kbps": round(rx_kbps, 3),
},
"sessions": {
"video": {
"app": video_app,
"kcp": local_sessions["video"]["kcp"],
},
"control": {
"app": control_app,
"kcp": local_sessions["control"]["kcp"],
},
},
"links": links,
"telemetry_receiver": {
"hub_connected": bool(telemetry_state.get("connected")),
"hub_updated_at": telemetry_state.get("updated_at"),
"hub_stale": remote_stale,
"last_error": telemetry_state.get("last_error", ""),
"peer_id": telemetry_state.get("peer_id", ""),
"registered": bool(telemetry_state.get("registered", False)),
"last_server_error": str(telemetry_state.get("last_server_error", "") or ""),
"reconnect_count": int(telemetry_state.get("reconnect_count", 0)),
},
"ingress": {
"native_udp": ingress_status,
},
"control": {
"arbiter": arbiter_status,
"sender": sender_status,
},
}
def close(self) -> None:
self._closing.set()
thread = self._sample_thread
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)

View File

@@ -1,378 +0,0 @@
from __future__ import annotations
from collections import deque
import sys
import threading
import time
from typing import Any, Iterator
from .common import (
JPEG_FRAME_DIR,
OMNISOCKET_CONFIG_PATH,
OMNISOCKET_FRAME_FRESH_SECONDS,
PROJECT_ROOT,
VIDEO_SOURCE_MODE,
VIDEO_TIMESTAMP_ENDIANNESS,
VIDEO_TIMESTAMP_MAX_SKEW_NS,
VIDEO_TIMESTAMP_MULTIPLIER_NS,
VIDEO_TIMESTAMP_SAMPLE_SIZE,
VIDEO_TIMESTAMP_TRAILER_BYTES,
VIDEO_TIMESTAMP_UNIT,
WORKSPACE_ROOT,
load_omnisocket_config,
)
def safe_kcp_stats(session: Any) -> dict[str, Any]:
if session is None or not hasattr(session, "kcp_stats"):
return {}
try:
return dict(session.kcp_stats())
except Exception:
return {}
class OmniSocketVideoReceiver:
def __init__(self) -> None:
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._started = False
self._session = None
self._session_cls = None
self._binary_msg_type = None
self._video_defaults: dict[str, Any] = {}
self._latest_frame: bytes | None = None
self._latest_received_at = 0.0
self._latest_sequence: int | None = None
self._latest_latency_ms: float | None = None
self._latest_timestamp_unit: str | None = None
self._latest_timestamp_endianness: str | None = None
self._latency_samples_ms: deque[float] = deque(maxlen=VIDEO_TIMESTAMP_SAMPLE_SIZE)
self._frames_received = 0
self._last_error = ""
self._reconnect_count = 0
self._ever_connected = False
self._closing = threading.Event()
self._load_backend()
def _load_backend(self) -> None:
try:
self._import_backend()
except Exception as error: # pragma: no cover - optional runtime dependency
self._last_error = f"omnisocket import failed: {error}"
def _import_backend(self) -> None:
try:
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
except ImportError:
python_dir = WORKSPACE_ROOT / "OmniSocketGo" / "python"
if python_dir.exists():
sys.path.insert(0, str(python_dir))
from omnisocket import MSG_TYPE_BINARY, Session, VIDEO_DEFAULTS # type: ignore
self._binary_msg_type = MSG_TYPE_BINARY
self._session_cls = Session
self._video_defaults = dict(VIDEO_DEFAULTS)
def ensure_started(self) -> None:
if self._session_cls is None or self._binary_msg_type is None:
return
with self._lock:
if self._started or self._closing.is_set():
return
self._started = True
self._thread = threading.Thread(
target=self._run,
name="omnisocket-video-receiver",
daemon=True,
)
self._thread.start()
def _connect_session(self):
assert self._session_cls is not None
config = load_omnisocket_config()
transport_cfg = config.get("transport", {})
video_cfg = config.get("video_receiver", {})
session = self._session_cls()
session.connect(
server_addr=str(transport_cfg.get("server_addr", "127.0.0.1:10909")),
peer_id=str(video_cfg.get("peer_id", "peer-a-video")),
relay_via=str(transport_cfg.get("relay_via", "")),
bind_ip=str(transport_cfg.get("bind_ip", "")),
bind_device=str(transport_cfg.get("bind_device", "")),
**self._video_defaults,
)
return session, int(video_cfg.get("buffer_bytes", 1024 * 1024))
def _extract_jpeg_payload(self, frame: bytes) -> bytes | None:
if frame.startswith(b"\xff\xd8"):
return frame
if len(frame) > 8 and frame[8:10] == b"\xff\xd8":
return frame[8:]
return None
def _split_jpeg_frame_and_trailer(self, frame: bytes) -> tuple[bytes, bytes] | None:
jpeg_payload = self._extract_jpeg_payload(frame)
if jpeg_payload is None:
return None
if jpeg_payload.endswith(b"\xff\xd9"):
return jpeg_payload, b""
if (
len(jpeg_payload) >= VIDEO_TIMESTAMP_TRAILER_BYTES + 2
and jpeg_payload[-(VIDEO_TIMESTAMP_TRAILER_BYTES + 2) : -VIDEO_TIMESTAMP_TRAILER_BYTES] == b"\xff\xd9"
):
return jpeg_payload[:-VIDEO_TIMESTAMP_TRAILER_BYTES], jpeg_payload[-VIDEO_TIMESTAMP_TRAILER_BYTES:]
eoi_index = jpeg_payload.rfind(b"\xff\xd9")
if eoi_index < 0:
return jpeg_payload, b""
trailer_start = eoi_index + 2
return jpeg_payload[:trailer_start], jpeg_payload[trailer_start:]
def _extract_jpeg_frame(self, frame: bytes) -> bytes | None:
split_payload = self._split_jpeg_frame_and_trailer(frame)
if split_payload is None:
return None
jpeg_frame, _ = split_payload
return jpeg_frame
def _extract_sequence(self, frame: bytes) -> int | None:
if len(frame) >= 8 and not frame.startswith(b"\xff\xd8"):
return int.from_bytes(frame[:8], "big")
return None
def _extract_frame_tail(self, frame: bytes) -> bytes:
split_payload = self._split_jpeg_frame_and_trailer(frame)
if split_payload is None:
return b""
_, trailer = split_payload
return trailer
def _extract_frame_timestamp(self, frame: bytes) -> tuple[int, str, str] | None:
trailer = self._extract_frame_tail(frame)
if len(trailer) != VIDEO_TIMESTAMP_TRAILER_BYTES:
return None
value = int.from_bytes(trailer, VIDEO_TIMESTAMP_ENDIANNESS, signed=False)
if value <= 0:
return None
timestamp_ns = value * VIDEO_TIMESTAMP_MULTIPLIER_NS
if abs(time.time_ns() - timestamp_ns) > VIDEO_TIMESTAMP_MAX_SKEW_NS:
return None
return timestamp_ns, VIDEO_TIMESTAMP_UNIT, VIDEO_TIMESTAMP_ENDIANNESS
def _run(self) -> None:
while not self._closing.is_set():
try:
session, buffer_bytes = self._connect_session()
with self._lock:
self._session = session
self._last_error = ""
if self._ever_connected:
self._reconnect_count += 1
else:
self._ever_connected = True
buffer = bytearray(buffer_bytes)
while not self._closing.is_set():
meta = session.recv_into(buffer, timeout_ms=1000)
if meta is None:
continue
if meta.get("msg_type") != self._binary_msg_type:
continue
frame = bytes(buffer[: meta["body_len"]])
jpeg_frame = self._extract_jpeg_frame(frame)
if jpeg_frame is None:
self._last_error = "received non-JPEG binary frame"
continue
timestamp_meta = self._extract_frame_timestamp(frame)
latency_ms = None
if timestamp_meta is not None:
timestamp_ns, unit, endianness = timestamp_meta
latency_ms = round((time.time_ns() - timestamp_ns) / 1_000_000, 3)
else:
unit = None
endianness = None
with self._lock:
self._latest_frame = jpeg_frame
self._latest_received_at = time.time()
self._latest_sequence = self._extract_sequence(frame)
self._latest_latency_ms = latency_ms
self._latest_timestamp_unit = unit
self._latest_timestamp_endianness = endianness
if latency_ms is not None:
self._latency_samples_ms.append(latency_ms)
self._frames_received += 1
except Exception as error: # pragma: no cover - runtime integration path
if not self._closing.is_set():
session_error = ""
if self._session is not None:
try:
session_error = str(dict(self._session.stats()).get("last_server_error", "") or "")
except Exception:
session_error = ""
self._last_error = session_error or str(error)
time.sleep(2)
finally:
if self._session is not None:
try:
self._session.close()
except Exception:
pass
with self._lock:
self._session = None
if self._closing.is_set():
self._started = False
def get_latest_frame(self) -> bytes | None:
self.ensure_started()
with self._lock:
if self._latest_frame is None:
return None
if time.time() - self._latest_received_at > OMNISOCKET_FRAME_FRESH_SECONDS:
return None
return self._latest_frame
def session_stats(self) -> dict[str, Any]:
self.ensure_started()
with self._lock:
session = self._session
if session is None:
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
try:
return dict(session.stats())
except Exception:
return {"connected": 0, "registered": 0, "last_server_error": self._last_error}
def session_kcp_stats(self) -> dict[str, Any]:
self.ensure_started()
with self._lock:
session = self._session
return safe_kcp_stats(session)
def get_status(self) -> dict[str, Any]:
self.ensure_started()
config = load_omnisocket_config()
transport_cfg = config.get("transport", {})
video_cfg = config.get("video_receiver", {})
session_stats = self.session_stats()
with self._lock:
has_recent_frame = self._latest_frame is not None and (
time.time() - self._latest_received_at <= OMNISOCKET_FRAME_FRESH_SECONDS
)
if has_recent_frame and self._latest_latency_ms is not None:
timing_status = {
"available": True,
"latest_delta_ms": self._latest_latency_ms,
"delta_samples_ms": list(reversed(self._latency_samples_ms)),
"sample_count": len(self._latency_samples_ms),
"sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE,
"timestamp_unit": self._latest_timestamp_unit,
"timestamp_endianness": self._latest_timestamp_endianness,
}
else:
timing_status = {
"available": False,
"latest_delta_ms": None,
"delta_samples_ms": [],
"sample_count": 0,
"sample_window_size": VIDEO_TIMESTAMP_SAMPLE_SIZE,
"timestamp_unit": None,
"timestamp_endianness": None,
}
return {
"backend_ready": self._session_cls is not None,
"mode": VIDEO_SOURCE_MODE,
"connected": self._session is not None,
"registered": bool(session_stats.get("registered", 0)),
"has_recent_frame": has_recent_frame,
"frames_received": self._frames_received,
"latest_sequence": self._latest_sequence,
"reconnect_count": self._reconnect_count,
"last_server_error": str(session_stats.get("last_server_error", "") or ""),
"last_error": self._last_error,
"config_path": str(OMNISOCKET_CONFIG_PATH),
"server_addr": str(transport_cfg.get("server_addr", "")),
"relay_via": str(transport_cfg.get("relay_via", "")),
"peer_id": str(video_cfg.get("peer_id", "")),
"buffer_bytes": int(video_cfg.get("buffer_bytes", 0)),
"timing": timing_status,
}
def close(self) -> None:
self._closing.set()
with self._lock:
session = self._session
if session is not None:
try:
session.close()
except Exception:
pass
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)
class VideoFrameService:
def __init__(self, receiver: OmniSocketVideoReceiver) -> None:
self._receiver = receiver
def get_status(self) -> dict[str, Any]:
receiver_status = self._receiver.get_status()
receiver_frame = self._receiver.get_latest_frame()
if receiver_frame is not None:
return {
"available": True,
"source_mode": "omnisocket-jpeg-live",
"frame_count": receiver_status["frames_received"],
"fps": 30,
"frame_dir": str(JPEG_FRAME_DIR),
"source_detail": f"peer stream active, frames={receiver_status['frames_received']}",
"receiver": receiver_status,
"timing": receiver_status["timing"],
}
wait_detail = receiver_status["last_error"] or (
"waiting for live OmniSocket JPEG frames; check the hub, sender, and receiver configuration"
)
return {
"available": False,
"source_mode": "omnisocket-waiting",
"frame_count": receiver_status["frames_received"],
"fps": 30,
"frame_dir": str(JPEG_FRAME_DIR),
"source_detail": wait_detail,
"receiver": receiver_status,
"timing": receiver_status["timing"],
}
def get_next_frame(self) -> bytes:
receiver_frame = self._receiver.get_latest_frame()
if receiver_frame is not None:
return receiver_frame
raise RuntimeError("no live OmniSocket JPEG frame is currently available")
def iter_mjpeg(self, fps: float = 6.0) -> Iterator[bytes]:
frame_interval = 1.0 / max(1.0, min(fps, 30.0))
while True:
frame = self.get_next_frame()
header = (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n"
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
)
yield header + frame + b"\r\n"
time.sleep(frame_interval)

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,21 +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_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

@@ -63,26 +63,24 @@ const navItems = [
.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) {
@@ -166,6 +164,7 @@ const navItems = [
@media (max-width: 960px) { @media (max-width: 960px) {
.topbar { .topbar {
position: static;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }

View File

@@ -1,527 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useControlInterface } from '@/composables/useControlInterface'
const props = withDefaults(defineProps<{
compact?: boolean
}>(), {
compact: false,
})
const {
activeSource,
activeSourceLabel,
commandLabel,
controlLimits,
controlInputMode,
controlInputModeLabel,
controlTuning,
commandValues,
gamepadActive,
gamepadButtons,
gamepadConnected,
gamepadIndex,
gamepadLeftStick,
gamepadMapping,
gamepadName,
gamepadRightStick,
keyboardActive,
keyboardKeys,
keyboardTurbo,
lastServerMessage,
pressedKeysLabel,
socketLabel,
socketState,
} = useControlInterface()
const keyClusters = computed(() => {
const lookup = new Map(keyboardKeys.value.map((entry) => [entry.code, entry]))
return [
lookup.get('KeyW'),
lookup.get('KeyA'),
lookup.get('KeyS'),
lookup.get('KeyD'),
lookup.get('KeyQ'),
lookup.get('KeyE'),
lookup.get('ShiftLeft'),
lookup.get('Space'),
].filter((entry): entry is NonNullable<typeof entry> => entry != null)
})
const commandBars = computed(() => [
{
label: 'Forward',
value: commandValues.value.lx,
max: controlLimits.value.forward,
},
{
label: 'Strafe',
value: commandValues.value.ly,
max: controlLimits.value.strafe,
},
{
label: 'Turn',
value: commandValues.value.az,
max: controlLimits.value.turn,
},
])
function meterPosition(value: number, max: number) {
const normalized = Math.max(-1, Math.min(1, value / max))
return `${50 + normalized * 45}%`
}
function stickOffset(value: number) {
return `${value * 22}px`
}
</script>
<template>
<section class="feedback-shell" :class="{ compact }">
<div class="feedback-topline">
<div class="headline-stack">
<div class="source-chip" :class="activeSource">
{{ activeSourceLabel }}
</div>
<div class="input-chip">
{{ controlInputModeLabel }} mode
</div>
</div>
<div class="status-stack">
<span class="socket-chip" :class="socketState">{{ socketLabel }}</span>
<span class="server-text">{{ lastServerMessage }}</span>
</div>
</div>
<div class="command-strip">
<div
v-for="bar in commandBars"
:key="bar.label"
class="command-card"
>
<div class="command-head">
<span>{{ bar.label }}</span>
<strong>{{ bar.value.toFixed(2) }}</strong>
</div>
<div class="command-meter">
<span class="center-line" />
<span class="command-dot" :style="{ left: meterPosition(bar.value, bar.max) }" />
</div>
</div>
</div>
<p class="summary">
Tuning: fwd {{ controlTuning.forward.toFixed(2) }} m/s, strafe {{ controlTuning.strafe.toFixed(2) }} m/s,
turn {{ controlTuning.turn.toFixed(2) }} rad/s, turbo x{{ controlTuning.turbo.toFixed(2) }}
</p>
<div class="feedback-grid" :class="{ compact }">
<section class="feedback-card">
<div class="card-head">
<div>
<p class="label">Keyboard</p>
<strong>{{ pressedKeysLabel }}</strong>
</div>
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? 'Turbo' : 'Selected') : 'Standby' }}
</span>
</div>
<div class="key-grid">
<span
v-for="key in keyClusters"
:key="key.code"
class="key-chip"
:class="{ active: key.pressed, wide: key.code === 'Space' || key.code === 'ShiftLeft' }"
>
{{ key.label }}
</span>
</div>
</section>
<section class="feedback-card">
<div class="card-head">
<div>
<p class="label">Gamepad</p>
<strong>{{ gamepadConnected ? gamepadName : 'Waiting for controller' }}</strong>
</div>
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
{{
gamepadConnected
? controlInputMode === 'gamepad'
? 'Selected'
: 'Standby'
: 'Offline'
}}
</span>
</div>
<p class="subtle">
{{
gamepadConnected
? `#${gamepadIndex} / mapping=${gamepadMapping || 'unknown'}`
: 'Left stick drives, right stick turns, RB boosts, A stops.'
}}
</p>
<div class="sticks">
<div class="stick-card">
<span>Left stick</span>
<div class="stick-pad">
<span class="crosshair crosshair-x" />
<span class="crosshair crosshair-y" />
<span
class="stick-dot"
:style="{
transform: `translate(${stickOffset(gamepadLeftStick.x)}, ${stickOffset(gamepadLeftStick.y)})`,
}"
/>
</div>
</div>
<div class="stick-card">
<span>Right stick</span>
<div class="stick-pad">
<span class="crosshair crosshair-x" />
<span class="crosshair crosshair-y" />
<span
class="stick-dot accent"
:style="{
transform: `translate(${stickOffset(gamepadRightStick.x)}, ${stickOffset(gamepadRightStick.y)})`,
}"
/>
</div>
</div>
</div>
<div class="button-grid">
<span
v-for="button in gamepadButtons"
:key="button.label"
class="button-chip"
:class="{ active: button.pressed }"
>
{{ button.label }}
</span>
</div>
</section>
</div>
<p v-if="!compact" class="summary accent">
Outgoing command: {{ commandLabel }}
</p>
</section>
</template>
<style scoped>
.feedback-shell {
display: grid;
gap: 14px;
}
.feedback-shell.compact {
gap: 12px;
}
.feedback-topline {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.headline-stack {
display: grid;
gap: 8px;
}
.status-stack {
display: grid;
justify-items: end;
gap: 6px;
min-width: 0;
}
.source-chip,
.input-chip,
.socket-chip,
.mode-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.source-chip {
background: rgba(78, 224, 168, 0.16);
color: #86f0c7;
}
.source-chip.keyboard {
background: rgba(91, 122, 255, 0.18);
color: #d3dcff;
}
.source-chip.gamepad {
background: rgba(255, 176, 87, 0.18);
color: #ffd8a6;
}
.source-chip.idle {
background: rgba(133, 147, 169, 0.16);
color: #cad3e8;
}
.input-chip {
background: rgba(123, 196, 255, 0.14);
color: #dff1ff;
}
.socket-chip {
background: rgba(40, 199, 111, 0.16);
color: #7ef0b5;
}
.socket-chip.connecting,
.socket-chip.closed {
background: rgba(255, 176, 87, 0.18);
color: #ffd29b;
}
.server-text {
max-width: 320px;
color: #aeb9d2;
font-size: 12px;
text-align: right;
line-height: 1.4;
word-break: break-word;
}
.command-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.command-card,
.feedback-card {
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.command-head,
.card-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.command-head span,
.label {
color: #8d99b3;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.command-head strong,
.card-head strong {
color: #f6f8fc;
font-size: 16px;
}
.command-meter {
position: relative;
height: 34px;
margin-top: 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 99, 99, 0.12), rgba(255, 255, 255, 0.05), rgba(78, 224, 168, 0.14));
border: 1px solid rgba(133, 147, 169, 0.16);
}
.center-line {
position: absolute;
top: 4px;
bottom: 4px;
left: 50%;
width: 1px;
background: rgba(222, 232, 255, 0.28);
}
.command-dot {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #fdfefe, #63e6a9 62%, #2d8e68 100%);
box-shadow: 0 0 16px rgba(99, 230, 169, 0.38);
transform: translate(-50%, -50%);
}
.feedback-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 12px;
}
.feedback-grid.compact {
grid-template-columns: 1fr;
}
.subtle,
.summary {
margin: 0;
color: #8d99b3;
line-height: 1.6;
}
.summary.accent {
color: #aeb9d2;
}
.mode-chip {
background: rgba(133, 147, 169, 0.14);
color: #cad3e8;
}
.mode-chip.hot {
background: rgba(255, 176, 87, 0.18);
color: #ffd29b;
}
.key-grid,
.button-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.key-chip,
.button-chip {
min-width: 44px;
min-height: 42px;
padding: 0 12px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(10, 20, 37, 0.9);
border: 1px solid rgba(133, 147, 169, 0.18);
color: #dfe7fb;
font-size: 13px;
font-weight: 700;
}
.key-chip.wide {
min-width: 88px;
}
.key-chip.active,
.button-chip.active {
background: linear-gradient(135deg, rgba(91, 122, 255, 0.28), rgba(77, 212, 172, 0.28));
border-color: rgba(123, 196, 255, 0.6);
color: #ffffff;
box-shadow: 0 8px 24px rgba(91, 122, 255, 0.22);
}
.sticks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.stick-card {
display: grid;
gap: 10px;
}
.stick-card span {
color: #aeb9d2;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stick-pad {
position: relative;
width: 84px;
height: 84px;
border-radius: 24px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: radial-gradient(circle at center, rgba(91, 122, 255, 0.12), rgba(4, 8, 15, 0.95));
}
.crosshair {
position: absolute;
background: rgba(222, 232, 255, 0.18);
}
.crosshair-x {
left: 14px;
right: 14px;
top: 50%;
height: 1px;
transform: translateY(-50%);
}
.crosshair-y {
top: 14px;
bottom: 14px;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.stick-dot {
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #f8fdff, #63e6a9 58%, #2a7e5f 100%);
box-shadow: 0 0 18px rgba(99, 230, 169, 0.35);
}
.stick-dot.accent {
background: radial-gradient(circle at 30% 30%, #fffaf4, #ffb057 58%, #b06d21 100%);
box-shadow: 0 0 18px rgba(255, 176, 87, 0.34);
}
@media (max-width: 960px) {
.command-strip,
.feedback-grid,
.sticks {
grid-template-columns: 1fr;
}
.status-stack {
justify-items: start;
}
.feedback-topline,
.command-head,
.card-head {
flex-direction: column;
align-items: start;
}
.server-text {
text-align: left;
}
}
</style>

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'
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
useControlInterface()
const inputModes = [
{ id: 'keyboard', label: 'Keyboard', detail: 'Use W/S, A/D, Q/E, Shift, and Space.' },
{ id: 'gamepad', label: 'Gamepad', detail: 'Use the browser-detected controller only.' },
] as const
const forwardSpeed = computed({
get: () => controlTuning.value.forward,
set: (value: number) => setControlTuning({ forward: value }),
})
const strafeSpeed = computed({
get: () => controlTuning.value.strafe,
set: (value: number) => setControlTuning({ strafe: value }),
})
const turnSpeed = computed({
get: () => controlTuning.value.turn,
set: (value: number) => setControlTuning({ turn: value }),
})
const turboMultiplier = computed({
get: () => controlTuning.value.turbo,
set: (value: number) => setControlTuning({ turbo: value }),
})
</script>
<template>
<section class="panel control-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Control</p>
<h2>Control Feedback</h2>
</div>
<button type="button" class="reset-button" @click="resetControlTuning">
Reset Defaults
</button>
</div>
<section class="mode-panel">
<div class="mode-panel-head">
<div>
<p class="mode-eyebrow">Input Mode</p>
<p class="mode-copy">Only one local input mode can control the page at a time.</p>
</div>
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
</div>
<div class="mode-toggle" role="radiogroup" aria-label="Control input mode">
<button
v-for="mode in inputModes"
:key="mode.id"
type="button"
class="mode-button"
:class="{ active: controlInputMode === mode.id }"
:aria-pressed="controlInputMode === mode.id"
@click="setControlInputMode(mode.id)"
>
<strong>{{ mode.label }}</strong>
<span>{{ mode.detail }}</span>
</button>
</div>
</section>
<div class="tuning-grid">
<label class="tuning-field">
<span>Forward</span>
<input v-model.number="forwardSpeed" type="number" min="0.05" max="3" step="0.05" />
<small>m/s</small>
</label>
<label class="tuning-field">
<span>Strafe</span>
<input v-model.number="strafeSpeed" type="number" min="0.05" max="3" step="0.05" />
<small>m/s</small>
</label>
<label class="tuning-field">
<span>Turn</span>
<input v-model.number="turnSpeed" type="number" min="0.05" max="3" step="0.05" />
<small>rad/s</small>
</label>
<label class="tuning-field">
<span>Turbo</span>
<input v-model.number="turboMultiplier" type="number" min="1" max="3" step="0.1" />
<small>x</small>
</label>
</div>
<ControlFeedback />
<p class="hint">
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> strafe, <code>Q/E</code> turn,
<code>Shift</code> turbo, <code>Space</code> stop.
</p>
<p class="hint subtle">
Speed tuning is shared by both local input modes and is saved in this browser.
</p>
<p class="hint subtle">
Browser gamepad mode uses the left stick to drive, the right stick to turn,
<code>RB</code> to boost, and <code>A</code> to send stop.
</p>
</section>
</template>
<style scoped>
.control-panel {
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.reset-button {
border: 1px solid rgba(133, 147, 169, 0.28);
background: rgba(10, 20, 37, 0.88);
color: #dfe7fb;
border-radius: 999px;
min-height: 36px;
padding: 0 14px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.reset-button:hover {
border-color: rgba(123, 196, 255, 0.48);
color: #ffffff;
}
.eyebrow {
margin: 0 0 4px;
color: #ffb057;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
h2 {
margin: 0;
font-size: 24px;
}
.mode-panel {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.mode-panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.mode-eyebrow {
margin: 0 0 4px;
color: #7bc4ff;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
.mode-copy {
margin: 0;
color: #d5dbee;
line-height: 1.6;
}
.mode-current {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(123, 196, 255, 0.14);
color: #dff1ff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mode-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mode-button {
display: grid;
gap: 6px;
min-height: 84px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: rgba(10, 20, 37, 0.9);
color: #dfe7fb;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.mode-button strong {
font-size: 15px;
}
.mode-button span {
color: #96a5c3;
font-size: 13px;
line-height: 1.5;
}
.mode-button:hover {
border-color: rgba(123, 196, 255, 0.4);
transform: translateY(-1px);
}
.mode-button.active {
border-color: rgba(123, 196, 255, 0.6);
background: linear-gradient(135deg, rgba(91, 122, 255, 0.24), rgba(77, 212, 172, 0.2));
box-shadow: 0 10px 28px rgba(91, 122, 255, 0.18);
}
.mode-button.active span {
color: #d5e7ff;
}
.tuning-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.tuning-field {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 16px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.tuning-field span,
.tuning-field small {
color: #aeb9d2;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tuning-field input {
width: 100%;
min-height: 42px;
border-radius: 12px;
border: 1px solid rgba(133, 147, 169, 0.24);
background: rgba(10, 20, 37, 0.96);
color: #f6f8fc;
padding: 0 12px;
font-size: 16px;
font-weight: 700;
}
.tuning-field input:focus {
outline: none;
border-color: rgba(123, 196, 255, 0.62);
box-shadow: 0 0 0 3px rgba(91, 122, 255, 0.18);
}
.hint {
margin: 0;
color: #d5dbee;
line-height: 1.7;
}
.hint.subtle {
color: #96a5c3;
}
@media (max-width: 960px) {
.panel-head {
flex-direction: column;
align-items: start;
}
.mode-panel-head {
flex-direction: column;
}
.tuning-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.mode-toggle,
.tuning-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,47 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types' import type { 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(() => props.network?.active_control_source ?? 'none')
function formatTime(value?: string | null) {
if (!value) {
return 'unavailable'
} }
return new Date(value).toLocaleString('zh-CN', { hour12: false }) return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
} })
function formatScalar(value?: number | string | null, suffix = '') {
if (value === null || value === undefined || value === '') {
return '--'
}
return `${value}${suffix}`
}
function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: LinkSessionTelemetry | null }> {
return [
{ name: 'control', data: link?.sessions?.control ?? null },
{ name: 'video', data: link?.sessions?.video ?? null },
]
}
</script> </script>
<template> <template>
@@ -49,139 +20,41 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
<div class="panel-head"> <div class="panel-head">
<div> <div>
<p class="eyebrow">Network</p> <p class="eyebrow">Network</p>
<h2>Dual-Leg Telemetry</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 ?? 'loading' }}
</span>
</div> </div>
<div class="stats"> <div class="stats">
<div class="stat-card"> <div class="stat-card">
<span>Latency</span> <span>延迟</span>
<strong>{{ formatScalar(network?.latency_ms, ' ms') }}</strong> <strong>{{ network?.latency_ms ?? '--' }} ms</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>Jitter</span> <span>抖动</span>
<strong>{{ formatScalar(network?.jitter_ms, ' ms') }}</strong> <strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>Active Control</span> <span>Retrans</span>
<strong>{{ activeSource }}</strong> <strong>{{ network?.retrans_pct ?? network?.packet_loss_pct ?? '--' }} %</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>Lease</span> <span>信号强度</span>
<strong>{{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</strong> <strong>{{ network?.signal_dbm ?? '--' }} dBm</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>TX Rate</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>RX Rate</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>Transport:</strong> {{ network?.transport ?? 'n/a' }} / {{ network?.source_mode ?? 'n/a' }}</p>
<p><strong>Telemetry Peer:</strong> {{ network?.telemetry_receiver?.peer_id ?? 'n/a' }}</p>
<p><strong>Telemetry Registered:</strong> {{ network?.telemetry_receiver?.registered ? 'yes' : 'no' }}</p>
<p><strong>Hub Freshness:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
<p><strong>Hub State:</strong> {{ network?.telemetry_receiver?.hub_stale ? 'stale' : 'fresh' }}</p>
<p><strong>Telemetry Reconnects:</strong> {{ network?.telemetry_receiver?.reconnect_count ?? 0 }}</p>
<p v-if="network?.telemetry_receiver?.last_error"><strong>Hub Error:</strong> {{ network?.telemetry_receiver?.last_error }}</p>
<p v-if="network?.telemetry_receiver?.last_server_error"><strong>Telemetry Session Error:</strong> {{ network?.telemetry_receiver?.last_server_error }}</p>
</div>
<div class="leg-grid">
<article v-for="leg in legCards" :key="leg.key" class="leg-card" :class="{ stale: leg.data?.stale }">
<div class="leg-head">
<div>
<p class="leg-label">{{ leg.label }}</p>
<h3>{{ leg.data?.source ?? 'waiting' }}</h3>
</div>
<div class="leg-meta">
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
{{ leg.data?.stale ? 'stale' : 'fresh' }}
</span>
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
</div>
</div>
<div class="aggregate-grid">
<div>
<span>Online</span>
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
</div>
<div>
<span>Max Pressure</span>
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
</div>
<div>
<span>Queued</span>
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
</div>
<div>
<span>In Flight Buffer</span>
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
</div>
<div>
<span>Retrans Delta</span>
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
</div>
<div>
<span>Repair Rate</span>
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
</div>
</div>
<div class="session-grid">
<section v-for="session in legSessions(leg.data)" :key="session.name" class="session-card">
<div class="session-head">
<div>
<p class="session-label">{{ session.name }}</p>
<h4>{{ session.data?.peer_id ?? 'unassigned' }}</h4>
</div>
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
{{ session.data?.connected ? 'online' : 'idle' }}
</span>
</div>
<div class="kv-grid">
<p><strong>Updated:</strong> {{ formatTime(session.data?.updated_at) }}</p>
<p><strong>SRTT:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
<p><strong>RTTVAR:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
<p><strong>RTO:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
<p><strong>SND WND:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
<p><strong>RMT WND:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
<p><strong>Inflight:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
<p><strong>Window Limit:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
<p><strong>Pressure:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
<p><strong>SND Queue:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ session.data?.trend?.snd_queue_trend ?? 'stable' }}</p>
<p><strong>SND Buffer:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ session.data?.trend?.snd_buffer_trend ?? 'stable' }}</p>
<p><strong>Queue Delta:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
<p><strong>Buffer Delta:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
<p><strong>Retrans:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
<p><strong>Fast Retrans:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
<p><strong>Lost:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
<p><strong>Repeat:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
<p><strong>Repair Rate:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</p>
<p v-if="session.data?.app"><strong>App Bytes:</strong> tx={{ session.data.app.send_bytes ?? 0 }} / rx={{ session.data.app.recv_bytes ?? 0 }}</p>
<p v-if="session.data?.app"><strong>Registered:</strong> {{ session.data.app.registered ? 'yes' : 'no' }}</p>
<p v-if="session.data?.app?.last_server_error"><strong>Server Error:</strong> {{ session.data.app.last_server_error }}</p>
</div>
</section>
</div>
</article>
</div>
<div class="summary"> <div class="summary">
<p><strong>Combined:</strong> sessions={{ network?.combined?.connected_sessions ?? 0 }} send={{ network?.combined?.send_bytes ?? 0 }}B recv={{ network?.combined?.recv_bytes ?? 0 }}B</p> <p><strong>来源</strong>{{ network?.transport ?? '暂无' }} / {{ network?.source_mode ?? '暂无' }}</p>
<p><strong>Native UDP:</strong> {{ network?.ingress?.native_udp?.bind_addr ?? 'n/a' }} packets={{ network?.ingress?.native_udp?.packets_received ?? 0 }} invalid={{ network?.ingress?.native_udp?.invalid_packets ?? 0 }}</p> <p><strong>刷新</strong>{{ updatedAt }}</p>
<p><strong>Control Sender:</strong> {{ network?.control?.sender?.peer_id ?? 'n/a' }} -> {{ network?.control?.sender?.target_peer ?? 'n/a' }} sends={{ network?.control?.sender?.send_count ?? 0 }} registered={{ network?.control?.sender?.registered ? 'yes' : 'no' }}</p>
<p><strong>Control Reconnects:</strong> {{ network?.control?.sender?.reconnect_count ?? 0 }}</p>
<p v-if="network?.control?.sender?.last_server_error"><strong>Control Session Error:</strong> {{ network?.control?.sender?.last_server_error }}</p>
</div> </div>
</section> </section>
</template> </template>
@@ -189,134 +62,73 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
<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;
} }
@@ -324,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,144 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
import { buildVideoStreamUrl } from '@/lib/api'
import type { VideoStatus } from '@/types' import type { VideoStatus } from '@/types'
const props = defineProps<{ const props = defineProps<{
video: VideoStatus | null video: VideoStatus | null
}>() }>()
const STATUS_REFRESH_MS = 300 const streamUrl = ref('')
const canRequestFrames = computed(() => props.video?.available === true)
const streamFps = computed(() => Math.max(props.video?.fps ?? 0, 30))
const liveVideo = ref<VideoStatus | null>(props.video) const senderProfileLabel = computed(() => {
const frameUrl = ref(buildVideoFrameUrl(0)) if (!props.video) {
const displayVideo = computed(() => liveVideo.value ?? props.video) return '发送端 Profile: --'
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
const canRequestFrames = computed(() => displayVideo.value?.available === true)
const modeLabel = computed(() => {
if (!displayVideo.value) {
return '正在获取视频状态'
} }
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') { return `发送端 Profile: ${props.video.fps} FPS`
return `${displayVideo.value.fps} FPS 实时接收`
}
if (displayVideo.value.source_mode === 'omnisocket-waiting') {
return '未实时获取真实值'
}
return `${displayVideo.value.fps} FPS`
}) })
const displayModeLabel = computed(() => {
if (!props.video) {
return '前端显示: 等待状态'
}
if (!canRequestFrames.value) {
return '前端显示: 等待新视频帧'
}
return `前端显示: MJPEG Stream (${streamFps.value} FPS 请求)`
})
const placeholderText = computed(() => { const placeholderText = computed(() => {
if (!displayVideo.value) { if (!props.video) {
return '正在获取视频状态...' return '正在获取视频状态...'
} }
return '未实时获取真实值' return '尚未收到实时视频帧'
})
const latencyLabels = computed(() => {
const sampleWindowSize = displayVideo.value?.timing?.sample_window_size ?? 10
const samples = displayVideo.value?.timing?.delta_samples_ms ?? []
return Array.from({ length: sampleWindowSize }, (_, index) => samples[index] ?? null)
})
const timingHeadline = computed(() => {
const latest = displayVideo.value?.timing?.latest_delta_ms
if (latest == null) {
return '等待帧尾时间'
}
return `最新 ${latest.toFixed(1)} ms`
})
const timingHint = computed(() => {
const timing = displayVideo.value?.timing
if (!timing?.available) {
return '当前还没有从 JPEG 结尾后的尾字节里解析到时间戳,标签会在收到有效帧尾时间后自动填充。'
}
const unitText = timing.timestamp_unit ? `,单位按 ${timing.timestamp_unit}` : ''
const endiannessText = timing.timestamp_endianness
? `,字节序按 ${timing.timestamp_endianness}`
: ''
return `最近 ${timing.sample_window_size} 个差值样本,面板按 ${STATUS_REFRESH_MS} ms 刷新一组${unitText}${endiannessText}`
}) })
let frameTimer: number | null = null function refreshStreamUrl() {
let statusTimer: number | null = null
let frameKey = 0
let statusRequestPending = false
async function refreshStatus() {
if (statusRequestPending) {
return
}
statusRequestPending = true
try {
liveVideo.value = await fetchVideoStatus()
} catch {
// 保持当前已显示状态,避免短暂请求失败把面板内容清空。
} finally {
statusRequestPending = false
}
}
function refreshFrame() {
if (!canRequestFrames.value) {
return
}
frameKey += 1
frameUrl.value = buildVideoFrameUrl(frameKey)
}
function startFrameLoop() {
if (frameTimer != null) {
window.clearInterval(frameTimer)
frameTimer = null
}
if (!canRequestFrames.value) { if (!canRequestFrames.value) {
streamUrl.value = ''
return return
} }
refreshFrame() // Keep the browser attached to a higher-frequency local MJPEG stream
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value)) // so the dashboard does not add an extra 0-100 ms polling delay.
frameTimer = window.setInterval(() => { streamUrl.value = buildVideoStreamUrl(streamFps.value, Date.now())
refreshFrame()
}, intervalMs)
} }
function startStatusLoop() { watch([streamFps, canRequestFrames], refreshStreamUrl, { immediate: true })
if (statusTimer != null) {
window.clearInterval(statusTimer)
statusTimer = null
}
void refreshStatus()
statusTimer = window.setInterval(() => {
void refreshStatus()
}, STATUS_REFRESH_MS)
}
onMounted(() => {
startStatusLoop()
startFrameLoop()
})
onUnmounted(() => {
if (frameTimer != null) {
window.clearInterval(frameTimer)
}
if (statusTimer != null) {
window.clearInterval(statusTimer)
}
})
watch(
() => props.video,
(nextVideo) => {
liveVideo.value = nextVideo
},
{ immediate: true },
)
watch([currentFps, canRequestFrames], () => {
startFrameLoop()
})
</script> </script>
<template> <template>
@@ -148,8 +57,8 @@ watch([currentFps, canRequestFrames], () => {
<p class="eyebrow">Video</p> <p class="eyebrow">Video</p>
<h2>JPEG 视频流</h2> <h2>JPEG 视频流</h2>
</div> </div>
<span class="badge" :class="{ bad: !displayVideo?.available }"> <span class="badge" :class="{ bad: !video?.available }">
{{ displayVideo?.source_mode ?? 'loading' }} {{ video?.source_mode ?? 'loading' }}
</span> </span>
</div> </div>
@@ -157,7 +66,7 @@ watch([currentFps, canRequestFrames], () => {
<img <img
v-if="canRequestFrames" v-if="canRequestFrames"
class="video-frame" class="video-frame"
:src="frameUrl" :src="streamUrl"
alt="Robot jpeg frame stream" alt="Robot jpeg frame stream"
/> />
<div v-else class="video-placeholder"> <div v-else class="video-placeholder">
@@ -168,41 +77,24 @@ watch([currentFps, canRequestFrames], () => {
<div class="stats"> <div class="stats">
<div class="stat-card"> <div class="stat-card">
<span>帧源</span> <span>帧源</span>
<strong>{{ displayVideo?.frame_count ?? '--' }} JPEG</strong> <strong>{{ video?.frame_count ?? '--' }} JPEG</strong>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span>当前模式</span> <span>当前模式</span>
<strong>{{ modeLabel }}</strong> <div class="stat-lines">
<strong>{{ senderProfileLabel }}</strong>
<strong class="secondary">{{ displayModeLabel }}</strong>
</div>
</div> </div>
</div> </div>
<div class="timing-panel">
<div class="timing-head">
<span>帧尾时间差</span>
<strong>{{ timingHeadline }}</strong>
</div>
<div class="timing-grid">
<span
v-for="(sample, index) in latencyLabels"
:key="index"
class="timing-label"
:class="{ empty: sample == null }"
>
{{ sample == null ? '--' : `${sample.toFixed(1)} ms` }}
</span>
</div>
<p class="hint subtle">
{{ timingHint }}
</p>
</div>
<p class="hint"> <p class="hint">
这里只有在后端已经收到 OmniSocket 的真实 JPEG 帧时才会开始逐帧请求并显示画面 视频可用时页面会直接连接后端的 MJPEG stream而不是按当前发送 fps 逐帧轮询
如果当前没有真实帧页面会保持占位提示不再回退测试视频流 这样能减少 dashboard 本身带来的额外显示延迟
</p> </p>
<p class="hint subtle"> <p class="hint subtle">
当前帧源状态{{ displayVideo?.source_detail ?? '暂无' }} 当前帧源状态{{ video?.source_detail ?? '暂无' }}
</p> </p>
</section> </section>
</template> </template>
@@ -249,7 +141,6 @@ h2 {
} }
.video-shell { .video-shell {
position: relative;
overflow: hidden; overflow: hidden;
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(133, 147, 169, 0.28); border: 1px solid rgba(133, 147, 169, 0.28);
@@ -302,55 +193,14 @@ h2 {
font-size: 18px; font-size: 18px;
} }
.timing-panel { .stat-lines {
display: grid; display: grid;
gap: 12px; gap: 6px;
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.88);
border: 1px solid rgba(133, 147, 169, 0.18);
} }
.timing-head { .stat-lines .secondary {
display: flex; font-size: 15px;
justify-content: space-between; color: #a8b4ce;
gap: 12px;
align-items: center;
color: #cfd7e6;
}
.timing-head span {
color: #8d99b3;
font-size: 12px;
}
.timing-head strong {
font-size: 16px;
}
.timing-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.timing-label {
display: grid;
place-items: center;
min-height: 40px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(91, 122, 255, 0.12);
border: 1px solid rgba(91, 122, 255, 0.28);
color: #dce4ff;
font-size: 12px;
font-weight: 700;
}
.timing-label.empty {
background: rgba(133, 147, 169, 0.08);
border-color: rgba(133, 147, 169, 0.18);
color: #7e8aa5;
} }
.hint { .hint {
@@ -367,9 +217,5 @@ h2 {
.stats { .stats {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.timing-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
</style> </style>

View File

@@ -1,710 +0,0 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { buildControlWebSocketUrl } from '@/lib/api'
type SocketState = 'connecting' | 'open' | 'closed'
type ControlInputMode = 'keyboard' | 'gamepad'
type ControlSource = 'keyboard' | 'gamepad' | 'idle'
type CommandTuple = [number, number, number, number, number, number]
type KeyFeedback = {
code: string
label: string
pressed: boolean
}
type ButtonFeedback = {
label: string
pressed: boolean
}
type ControlTuning = {
forward: number
strafe: number
turn: number
turbo: number
}
const TRACKED_KEYS = ['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space']
const KEY_LABELS: Record<string, string> = {
KeyW: 'W',
KeyS: 'S',
KeyA: 'A',
KeyD: 'D',
KeyQ: 'Q',
KeyE: 'E',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
Space: 'Stop',
}
const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS']
const ZERO_COMMAND: CommandTuple = [0, 0, 0, 0, 0, 0]
const GAMEPAD_DEADZONE = 0.14
const COMMAND_SEND_INTERVAL_MS = 50
const DEFAULT_CONTROL_TUNING: ControlTuning = {
forward: 0.8,
strafe: 0.15,
turn: 0.4,
turbo: 1.5,
}
const CONTROL_INPUT_MODE_STORAGE_KEY = 'robot-command-center.control-input-mode'
const CONTROL_TUNING_STORAGE_KEY = 'robot-command-center.control-tuning'
const MIN_AXIS_SPEED = 0.05
const MAX_AXIS_SPEED = 3
const MIN_TURBO_MULTIPLIER = 1
const MAX_TURBO_MULTIPLIER = 3
const pressedKeys = ref<Set<string>>(new Set())
const socketState = ref<SocketState>('connecting')
const lastServerMessage = ref('waiting')
const gamepadSupported = ref(false)
const gamepadConnected = ref(false)
const gamepadName = ref('No gamepad detected')
const gamepadIndex = ref<number | null>(null)
const gamepadMapping = ref('')
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
const gamepadButtonPressed = ref<boolean[]>(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false))
const activeSource = ref<ControlSource>('idle')
function clampValue(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function sanitizeAxisSpeed(value: unknown, fallback: number) {
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numericValue)) {
return fallback
}
return roundValue(clampValue(numericValue, MIN_AXIS_SPEED, MAX_AXIS_SPEED))
}
function sanitizeTurboMultiplier(value: unknown, fallback: number) {
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numericValue)) {
return fallback
}
return roundValue(clampValue(numericValue, MIN_TURBO_MULTIPLIER, MAX_TURBO_MULTIPLIER))
}
function normalizeControlTuning(raw?: Partial<ControlTuning>): ControlTuning {
return {
forward: sanitizeAxisSpeed(raw?.forward, DEFAULT_CONTROL_TUNING.forward),
strafe: sanitizeAxisSpeed(raw?.strafe, DEFAULT_CONTROL_TUNING.strafe),
turn: sanitizeAxisSpeed(raw?.turn, DEFAULT_CONTROL_TUNING.turn),
turbo: sanitizeTurboMultiplier(raw?.turbo, DEFAULT_CONTROL_TUNING.turbo),
}
}
function normalizeControlInputMode(raw: unknown): ControlInputMode {
return raw === 'gamepad' ? 'gamepad' : 'keyboard'
}
function loadPersistedControlTuning() {
if (typeof window === 'undefined') {
return DEFAULT_CONTROL_TUNING
}
let raw: string | null = null
try {
raw = window.localStorage.getItem(CONTROL_TUNING_STORAGE_KEY)
} catch {
return DEFAULT_CONTROL_TUNING
}
if (raw == null) {
return DEFAULT_CONTROL_TUNING
}
try {
return normalizeControlTuning(JSON.parse(raw) as Partial<ControlTuning>)
} catch {
return DEFAULT_CONTROL_TUNING
}
}
function loadPersistedControlInputMode() {
if (typeof window === 'undefined') {
return normalizeControlInputMode(null)
}
try {
return normalizeControlInputMode(window.localStorage.getItem(CONTROL_INPUT_MODE_STORAGE_KEY))
} catch {
return normalizeControlInputMode(null)
}
}
const controlInputMode = ref<ControlInputMode>(loadPersistedControlInputMode())
const initialControlTuning = loadPersistedControlTuning()
const forwardSpeed = ref(initialControlTuning.forward)
const strafeSpeed = ref(initialControlTuning.strafe)
const turnSpeed = ref(initialControlTuning.turn)
const turboMultiplier = ref(initialControlTuning.turbo)
let socket: WebSocket | null = null
let sendTimer: number | null = null
let reconnectTimer: number | null = null
let gamepadTimer: number | null = null
let manualClose = false
let consumerCount = 0
let lastGamepadSignature = ''
let lastCommandSignature = ''
function normalizeAxis(raw: number) {
if (Math.abs(raw) < GAMEPAD_DEADZONE) {
return 0
}
const sign = raw >= 0 ? 1 : -1
return sign * ((Math.abs(raw) - GAMEPAD_DEADZONE) / (1 - GAMEPAD_DEADZONE))
}
function roundValue(value: number) {
return Math.round(value * 1000) / 1000
}
function persistControlTuning() {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(
CONTROL_TUNING_STORAGE_KEY,
JSON.stringify({
forward: forwardSpeed.value,
strafe: strafeSpeed.value,
turn: turnSpeed.value,
turbo: turboMultiplier.value,
}),
)
} catch {
// Ignore storage failures so tuning still works for the current session.
}
}
function persistControlInputMode() {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(CONTROL_INPUT_MODE_STORAGE_KEY, controlInputMode.value)
} catch {
// Ignore storage failures so mode switching still works for the current session.
}
}
function setControlInputMode(next: ControlInputMode) {
const resolved = normalizeControlInputMode(next)
const previous = controlInputMode.value
if (resolved === previous) {
return
}
controlInputMode.value = resolved
persistControlInputMode()
if (previous === 'keyboard') {
pressedKeys.value = new Set()
}
refreshSendLoop(true)
}
function setControlTuning(next: Partial<ControlTuning>) {
const resolved = normalizeControlTuning({
forward: next.forward ?? forwardSpeed.value,
strafe: next.strafe ?? strafeSpeed.value,
turn: next.turn ?? turnSpeed.value,
turbo: next.turbo ?? turboMultiplier.value,
})
const changed =
resolved.forward !== forwardSpeed.value ||
resolved.strafe !== strafeSpeed.value ||
resolved.turn !== turnSpeed.value ||
resolved.turbo !== turboMultiplier.value
forwardSpeed.value = resolved.forward
strafeSpeed.value = resolved.strafe
turnSpeed.value = resolved.turn
turboMultiplier.value = resolved.turbo
persistControlTuning()
if (changed) {
refreshSendLoop(true)
}
}
function resetControlTuning() {
setControlTuning(DEFAULT_CONTROL_TUNING)
}
function packCommand(values: CommandTuple) {
const buffer = new ArrayBuffer(24)
const view = new DataView(buffer)
values.forEach((value, index) => view.setFloat32(index * 4, value, true))
return buffer
}
function isZeroCommand(values: CommandTuple) {
return values.every((value) => Math.abs(value) < 0.0001)
}
function commandSignature(values: CommandTuple, source: ControlSource) {
return `${source}:${values.map((value) => value.toFixed(3)).join(',')}`
}
function activeTurnAxis() {
const axis2 = normalizeAxis(gamepadAxes.value[2] ?? 0)
const axis3 = normalizeAxis(gamepadAxes.value[3] ?? 0)
return Math.abs(axis2) >= Math.abs(axis3) ? axis2 : axis3
}
function keyboardCommandValues(): CommandTuple {
const keys = pressedKeys.value
const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? turboMultiplier.value : 1
let lx = 0
let ly = 0
let az = 0
if (keys.has('KeyW')) lx += forwardSpeed.value
if (keys.has('KeyS')) lx -= forwardSpeed.value
if (keys.has('KeyA')) ly += strafeSpeed.value
if (keys.has('KeyD')) ly -= strafeSpeed.value
if (keys.has('KeyQ')) az += turnSpeed.value
if (keys.has('KeyE')) az -= turnSpeed.value
if (keys.has('Space')) {
return ZERO_COMMAND
}
return [
roundValue(lx * turbo),
roundValue(ly * turbo),
0,
0,
0,
roundValue(az * turbo),
]
}
function gamepadCommandValues(): CommandTuple {
if (!gamepadConnected.value) {
return ZERO_COMMAND
}
const buttons = gamepadButtonPressed.value
const turbo = buttons[5] ? turboMultiplier.value : 1
if (buttons[0]) {
return ZERO_COMMAND
}
const lx = roundValue(-normalizeAxis(gamepadAxes.value[1] ?? 0) * forwardSpeed.value * turbo)
const ly = roundValue(-normalizeAxis(gamepadAxes.value[0] ?? 0) * strafeSpeed.value * turbo)
const az = roundValue(-activeTurnAxis() * turnSpeed.value * turbo)
return [lx, ly, 0, 0, 0, az]
}
function keyboardActiveRaw() {
return pressedKeys.value.size > 0
}
function keyboardActive() {
return controlInputMode.value === 'keyboard' && keyboardActiveRaw()
}
function gamepadActiveRaw() {
if (!gamepadConnected.value) {
return false
}
return !isZeroCommand(gamepadCommandValues()) || gamepadButtonPressed.value.some(Boolean)
}
function gamepadActiveInternal() {
return controlInputMode.value === 'gamepad' && gamepadActiveRaw()
}
function resolvedSource(): ControlSource {
if (controlInputMode.value === 'keyboard' && keyboardActiveRaw()) {
return 'keyboard'
}
if (controlInputMode.value === 'gamepad' && gamepadActiveRaw()) {
return 'gamepad'
}
return 'idle'
}
function resolvedCommandValues(): CommandTuple {
const source = resolvedSource()
activeSource.value = source
if (source === 'keyboard') {
return keyboardCommandValues()
}
if (source === 'gamepad') {
return gamepadCommandValues()
}
return ZERO_COMMAND
}
function stopSendLoop() {
if (sendTimer != null) {
window.clearInterval(sendTimer)
sendTimer = null
}
}
function sendCurrentCommand() {
if (socket == null || socket.readyState !== WebSocket.OPEN) {
return
}
socket.send(packCommand(resolvedCommandValues()))
}
function refreshSendLoop(force = false) {
const source = resolvedSource()
const values = resolvedCommandValues()
const signature = commandSignature(values, source)
if (!force && signature === lastCommandSignature) {
return
}
lastCommandSignature = signature
stopSendLoop()
if (socket == null || socket.readyState !== WebSocket.OPEN) {
return
}
sendCurrentCommand()
if (isZeroCommand(values)) {
return
}
sendTimer = window.setInterval(() => {
sendCurrentCommand()
}, COMMAND_SEND_INTERVAL_MS)
}
function clearKeyboardCommands() {
pressedKeys.value = new Set()
refreshSendLoop()
}
function handleKeydown(event: KeyboardEvent) {
if (!TRACKED_KEYS.includes(event.code)) {
return
}
if (controlInputMode.value !== 'keyboard') {
return
}
if (event.target instanceof HTMLElement) {
const tag = event.target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return
}
}
event.preventDefault()
const next = new Set(pressedKeys.value)
next.add(event.code)
pressedKeys.value = next
refreshSendLoop()
}
function handleKeyup(event: KeyboardEvent) {
if (!TRACKED_KEYS.includes(event.code)) {
return
}
if (controlInputMode.value !== 'keyboard') {
return
}
event.preventDefault()
const next = new Set(pressedKeys.value)
next.delete(event.code)
pressedKeys.value = next
refreshSendLoop()
}
function resetGamepadState() {
gamepadConnected.value = false
gamepadName.value = 'No gamepad detected'
gamepadIndex.value = null
gamepadMapping.value = ''
gamepadAxes.value = [0, 0, 0, 0]
gamepadButtonPressed.value = Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false)
}
function pollGamepadState() {
gamepadSupported.value = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
if (!gamepadSupported.value) {
resetGamepadState()
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
return
}
const pad = Array.from(navigator.getGamepads()).find((entry): entry is Gamepad => entry != null)
if (pad == null) {
if (gamepadConnected.value) {
resetGamepadState()
lastGamepadSignature = ''
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
}
return
}
const axes = Array.from({ length: 4 }, (_, index) => roundValue(normalizeAxis(pad.axes[index] ?? 0)))
const buttons = GAMEPAD_BUTTON_LABELS.map((_, index) => Boolean(pad.buttons[index]?.pressed))
const signature = `${pad.index}:${pad.id}:${pad.mapping}:${axes.join(',')}:${buttons.map((pressed) => (pressed ? '1' : '0')).join('')}`
if (signature === lastGamepadSignature) {
return
}
lastGamepadSignature = signature
gamepadConnected.value = true
gamepadName.value = pad.id || 'Unnamed gamepad'
gamepadIndex.value = pad.index
gamepadMapping.value = pad.mapping || 'unknown'
gamepadAxes.value = axes
gamepadButtonPressed.value = buttons
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
}
function connectSocket() {
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return
}
manualClose = false
socketState.value = 'connecting'
socket = new WebSocket(buildControlWebSocketUrl())
socket.binaryType = 'arraybuffer'
socket.onopen = () => {
socketState.value = 'open'
lastServerMessage.value = 'control link live'
refreshSendLoop(true)
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
lastServerMessage.value = event.data
}
}
socket.onclose = () => {
socketState.value = 'closed'
stopSendLoop()
socket = null
if (manualClose) {
return
}
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer)
}
reconnectTimer = window.setTimeout(() => {
connectSocket()
}, 1000)
}
}
function disconnectSocket() {
manualClose = true
stopSendLoop()
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
socket?.close()
socket = null
}
function startGamepadLoop() {
if (gamepadTimer != null) {
window.clearInterval(gamepadTimer)
}
pollGamepadState()
gamepadTimer = window.setInterval(() => {
pollGamepadState()
}, COMMAND_SEND_INTERVAL_MS)
}
function stopGamepadLoop() {
if (gamepadTimer != null) {
window.clearInterval(gamepadTimer)
gamepadTimer = null
}
}
function attachGlobalListeners() {
connectSocket()
startGamepadLoop()
window.addEventListener('keydown', handleKeydown)
window.addEventListener('keyup', handleKeyup)
window.addEventListener('blur', clearKeyboardCommands)
window.addEventListener('gamepadconnected', pollGamepadState)
window.addEventListener('gamepaddisconnected', pollGamepadState)
}
function detachGlobalListeners() {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('keyup', handleKeyup)
window.removeEventListener('blur', clearKeyboardCommands)
window.removeEventListener('gamepadconnected', pollGamepadState)
window.removeEventListener('gamepaddisconnected', pollGamepadState)
clearKeyboardCommands()
stopGamepadLoop()
disconnectSocket()
}
function mountConsumer() {
consumerCount += 1
if (consumerCount === 1) {
attachGlobalListeners()
}
}
function unmountConsumer() {
consumerCount = Math.max(consumerCount - 1, 0)
if (consumerCount === 0) {
detachGlobalListeners()
}
}
const socketLabel = computed(() => {
if (socketState.value === 'open') return 'ws open'
if (socketState.value === 'connecting') return 'connecting'
return 'reconnecting'
})
const activeSourceLabel = computed(() => {
if (activeSource.value === 'keyboard') return 'Keyboard'
if (activeSource.value === 'gamepad') return 'Gamepad'
return 'Idle'
})
const controlInputModeLabel = computed(() => {
if (controlInputMode.value === 'gamepad') return 'Gamepad'
return 'Keyboard'
})
const commandValues = computed(() => {
const [lx, ly, lz, ax, ay, az] = resolvedCommandValues()
return { lx, ly, lz, ax, ay, az }
})
const commandLabel = computed(() => {
const { lx, ly, az } = commandValues.value
return `lx=${lx.toFixed(2)} ly=${ly.toFixed(2)} az=${az.toFixed(2)}`
})
const commandMagnitude = computed(() => {
const { lx, ly, az } = commandValues.value
const limits = controlLimits.value
return Math.min(
1,
Math.max(
Math.abs(lx) / Math.max(limits.forward, MIN_AXIS_SPEED),
Math.abs(ly) / Math.max(limits.strafe, MIN_AXIS_SPEED),
Math.abs(az) / Math.max(limits.turn, MIN_AXIS_SPEED),
),
)
})
const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none')
const keyboardKeys = computed<KeyFeedback[]>(() =>
TRACKED_KEYS.map((code) => ({
code,
label: KEY_LABELS[code] ?? code,
pressed: pressedKeys.value.has(code),
})),
)
const keyboardTurbo = computed(
() => controlInputMode.value === 'keyboard' && (pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight')),
)
const controlTuning = computed<ControlTuning>(() => ({
forward: forwardSpeed.value,
strafe: strafeSpeed.value,
turn: turnSpeed.value,
turbo: turboMultiplier.value,
}))
const controlLimits = computed(() => ({
forward: roundValue(forwardSpeed.value * turboMultiplier.value),
strafe: roundValue(strafeSpeed.value * turboMultiplier.value),
turn: roundValue(turnSpeed.value * turboMultiplier.value),
}))
const gamepadButtons = computed<ButtonFeedback[]>(() =>
GAMEPAD_BUTTON_LABELS.map((label, index) => ({
label,
pressed: gamepadButtonPressed.value[index] ?? false,
})),
)
const gamepadLeftStick = computed(() => ({
x: gamepadAxes.value[0] ?? 0,
y: gamepadAxes.value[1] ?? 0,
}))
const gamepadRightStick = computed(() => ({
x: activeTurnAxis(),
y: gamepadAxes.value[3] ?? 0,
}))
export function useControlInterface() {
onMounted(() => {
mountConsumer()
})
onUnmounted(() => {
unmountConsumer()
})
return {
controlInputMode,
controlInputModeLabel,
setControlInputMode,
socketState,
socketLabel,
lastServerMessage,
activeSource,
activeSourceLabel,
commandValues,
commandLabel,
commandMagnitude,
controlTuning,
controlLimits,
setControlTuning,
resetControlTuning,
pressedKeysLabel,
keyboardKeys,
keyboardTurbo,
keyboardActive: computed(() => keyboardActive()),
gamepadSupported,
gamepadConnected,
gamepadName,
gamepadIndex,
gamepadMapping,
gamepadButtons,
gamepadLeftStick,
gamepadRightStick,
gamepadAxes,
gamepadActive: computed(() => gamepadActiveInternal()),
}
}

View File

@@ -13,7 +13,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
const video = ref<VideoStatus | null>(null) const video = ref<VideoStatus | null>(null)
const loading = ref(true) const loading = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000) const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 500)
let refreshTimer: number | null = null let refreshTimer: number | null = null
@@ -25,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 : 'Failed to load monitoring data' errorMessage.value = error instanceof Error ? error.message : '数据加载失败'
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -36,9 +36,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
return errorMessage.value return errorMessage.value
} }
if (loading.value) { if (loading.value) {
return 'Connecting to the Django backend and loading live monitoring data...' return '正在连接 Django 后端并加载监控数据...'
} }
return 'Dashboard connected. Video, GPS, and live session telemetry refresh continuously from the unified A-side daemon.' return '页面已连接 Django 后端。GPS 与网络状态按当前页面策略轮询更新,视频区域单独按目标 30FPS 请求单帧 JPEG。'
}) })
onMounted(() => { onMounted(() => {

View File

@@ -1,4 +1,4 @@
import type { DashboardSnapshot, VideoStatus } from '@/types' import type { DashboardSnapshot } from '@/types'
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
@@ -16,20 +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 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 function buildControlWebSocketUrl() { export function buildVideoStreamUrl(fps: number, token: number) {
const url = new URL(API_BASE, window.location.origin) return `${API_BASE}/api/video/stream/?fps=${fps}&t=${token}`
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

@@ -12,171 +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
srttvar_ms?: number
snd_wnd?: number
rmt_wnd?: number
inflight?: number
window_limit?: number
window_pressure_pct?: number
snd_queue?: number
rcv_queue?: number
snd_buffer?: number
out_segs_total?: number
retrans_total?: number
fast_retrans_total?: number
lost_total?: number
repeat_total?: number
xmit_total?: number
}
export interface SessionTelemetry {
app: SessionAppStats
kcp: SessionKcpStats
}
export interface SessionTrendStats {
snd_queue_delta: number
snd_buffer_delta: number
snd_queue_trend: string
snd_buffer_trend: string
retrans_delta: number
fast_retrans_delta: number
lost_delta: number
repeat_delta: number
out_segs_delta: number
repair_rate_pct: number
}
export interface LinkSessionTelemetry {
peer_id: string
connected: boolean
updated_at: string | null
stale: boolean
app: SessionAppStats | null
kcp: SessionKcpStats
trend: SessionTrendStats
}
export interface LinkAggregateTelemetry {
online_sessions: number
max_window_pressure_pct: number
sum_snd_queue: number
sum_snd_buffer: number
sum_retrans_delta: number
sum_out_segs_delta: number
repair_rate_pct: number
}
export interface LinkTelemetry {
source: string
updated_at: string | null
stale: boolean
aggregate: LinkAggregateTelemetry
sessions: {
control: LinkSessionTelemetry
video: LinkSessionTelemetry
}
}
export interface NativeUdpIngress {
started: boolean
bind_addr: string
packets_received: number
invalid_packets: number
last_sender: string
last_error: string
}
export interface ControlArbiterStatus {
active_source: string | null
control_lease_remaining_ms: number
packet_counts: Record<string, number>
send_rate_hz: number
source_lease_ms: number
zero_burst_packets: number
last_error: string
last_sent_at_monotonic: number
}
export interface ControlSenderStatus {
backend_ready: boolean
started: boolean
connected: boolean
registered: boolean
peer_id: string
target_peer: string
send_count: number
send_errors: number
drain_errors: number
reconnect_count: number
last_server_error: string
last_error: string
}
export interface TelemetryReceiverStatus {
hub_connected: boolean
hub_updated_at: string | null
hub_stale: boolean
last_error: string
peer_id: string
registered: boolean
last_server_error: string
reconnect_count: number
}
export interface NetworkTelemetry { 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
}
telemetry_receiver: TelemetryReceiverStatus
ingress: {
native_udp: NativeUdpIngress
}
control: {
arbiter: ControlArbiterStatus
sender: ControlSenderStatus
}
} }
export interface VideoStatus { export interface VideoStatus {
@@ -186,40 +33,19 @@ export interface VideoStatus {
fps: number fps: number
frame_dir: string frame_dir: string
source_detail?: string source_detail?: string
timing?: {
available: boolean
latest_delta_ms: number | null
delta_samples_ms: number[]
sample_count: number
sample_window_size: number
timestamp_unit: string | null
timestamp_endianness: string | null
}
receiver?: { 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
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
latest_delta_ms: number | null
delta_samples_ms: number[]
sample_count: number
sample_window_size: number
timestamp_unit: string | null
timestamp_endianness: string | null
}
} }
} }

View File

@@ -1,5 +1,4 @@
<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'
@@ -13,11 +12,11 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
<header class="hero"> <header class="hero">
<div> <div>
<p class="eyebrow">Overview</p> <p class="eyebrow">Overview</p>
<h1>Robot Command Center</h1> <h1>机器人竞赛指挥台</h1>
</div> </div>
<p class="hero-text"> <p class="hero-text">
The A-side daemon now owns video receive, control ingress arbitration, and live session 当前版本已经接通三块核心能力JPEG 视频流GPS 地图定位网络状态展示后面接真实
telemetry in one backend process. C 数据源时前端页面不需要大改
</p> </p>
</header> </header>
@@ -26,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" />
<ControlPanel />
</section>
<GpsMapPanel :gps="gps" /> <GpsMapPanel :gps="gps" />
<NetworkPanel :network="network" /> <NetworkPanel :network="network" />
</main> </main>
@@ -90,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

@@ -2,9 +2,7 @@
import NetworkPanel from '@/components/NetworkPanel.vue' import NetworkPanel from '@/components/NetworkPanel.vue'
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>
@@ -12,12 +10,10 @@ const { network, errorMessage, headerStatus } = useMonitoringData({
<header class="page-header"> <header class="page-header">
<div> <div>
<p class="eyebrow">Network</p> <p class="eyebrow">Network</p>
<h1>Network Telemetry</h1> <h1>网络状态页面</h1>
</div> </div>
<p class="description"> <p class="description">
Live dual-leg OmniSocket telemetry from the A-side daemon, separating the local `A <-> D` 当前先展示模拟网络遥测数据后续只需要把后端采集函数替换成真实 C 输出就能保留同样的渲染界面
sessions from the hub-reported `D <-> B` leg with queue pressure, retransmission, and stale-link
visibility.
</p> </p>
</header> </header>
@@ -73,3 +69,4 @@ h1 {
border-color: rgba(255, 107, 107, 0.28); border-color: rgba(255, 107, 107, 0.28);
} }
</style> </style>