feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""Build K1 observation layouts with the repository's pinned Rerun SDK.
|
||||
|
||||
These assets contain renderer configuration only, never captured device data.
|
||||
Run with .venv/bin/python scripts/build_observation_blueprints.py.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
from k1link.viewer.rerun_bridge import _live_time_panel
|
||||
|
||||
destination = Path(__file__).resolve().parents[1] / "plugins/xgrids-k1/frontend/src/sensors"
|
||||
for mode, position, up in (
|
||||
("3d", [16.0, -16.0, 18.0], [0.0, 0.0, 1.0]),
|
||||
("plan", [0.0, 0.0, 30.0], [0.0, 1.0, 0.0]),
|
||||
):
|
||||
view = rrb.Spatial3DView(
|
||||
origin="/world", contents=["/world/**"], name="K1 · " + mode,
|
||||
eye_controls=rrb.EyeControls3D.from_fields(
|
||||
kind=rrb.Eye3DKind.Orbital, position=position,
|
||||
look_target=[0.0, 0.0, 0.0], eye_up=up,
|
||||
tracking_entity="/world/sensor_pose",
|
||||
),
|
||||
time_ranges=[rr.VisibleTimeRange("stream_time", start=rr.TimeRangeBoundary.cursor_relative(seconds=-12), end=rr.TimeRangeBoundary.cursor_relative())],
|
||||
)
|
||||
rrb.Blueprint(view, _live_time_panel(), auto_layout=False, auto_views=False, collapse_panels=True).save(
|
||||
"nodedc_mission_core_spatial", destination / f"observation-{mode}.rbl"
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan/apply a loopback-only SSH link to the existing NAS Map Gateway.
|
||||
|
||||
No remote command, NAS configuration, provider credential or cache is changed.
|
||||
The user LaunchAgent reconnects independently of the Core process.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LABEL = "com.nodedc.mission-core.map-gateway-link"
|
||||
PORT = 18103
|
||||
|
||||
|
||||
def payload(target: str, runtime: Path) -> bytes:
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_][a-zA-Z0-9_.-]*@[a-zA-Z0-9][a-zA-Z0-9.-]*", target):
|
||||
raise ValueError("SSH target must be an explicit user@host")
|
||||
return plistlib.dumps({
|
||||
"Label": LABEL,
|
||||
"ProgramArguments": [
|
||||
"/usr/bin/ssh", "-N", "-T", "-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=3", "-o", "PermitLocalCommand=no",
|
||||
"-o", "ForwardAgent=no", "-o", "ForwardX11=no",
|
||||
"-L", f"127.0.0.1:{PORT}:127.0.0.1:{PORT}", target,
|
||||
],
|
||||
"RunAtLoad": True, "KeepAlive": True, "ThrottleInterval": 30,
|
||||
"ExitTimeOut": 10,
|
||||
"StandardOutPath": str(runtime / "stdout.log"),
|
||||
"StandardErrorPath": str(runtime / "stderr.log"),
|
||||
}, sort_keys=True)
|
||||
|
||||
|
||||
def digest(value: bytes | None) -> str:
|
||||
return hashlib.sha256(value).hexdigest() if value is not None else "absent"
|
||||
|
||||
|
||||
def health() -> bool:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", PORT, timeout=2)
|
||||
try:
|
||||
connection.request("GET", "/healthz", headers={
|
||||
"x-nodedc-user-id": "mission-core-loopback-operator",
|
||||
})
|
||||
response = connection.getresponse()
|
||||
raw = response.read(65537)
|
||||
if response.status != 200 or len(raw) > 65536:
|
||||
return False
|
||||
value = json.loads(raw)
|
||||
return value.get("ok") is True and value.get("service") == "nodedc-map-gateway"
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
return False
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def loaded() -> bool:
|
||||
return subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"],
|
||||
capture_output=True, timeout=5, check=False).returncode == 0
|
||||
|
||||
|
||||
def unload() -> None:
|
||||
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"],
|
||||
capture_output=True, timeout=20, check=False)
|
||||
deadline = time.monotonic() + 20
|
||||
while loaded():
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Map Gateway link did not finish unloading")
|
||||
time.sleep(.2)
|
||||
|
||||
|
||||
def write_atomic(path: Path, value: bytes) -> None:
|
||||
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".map-link-")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(value)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["plan", "apply", "status"])
|
||||
parser.add_argument("--target")
|
||||
parser.add_argument("--expected-current-sha256")
|
||||
parser.add_argument("--expected-desired-sha256")
|
||||
args = parser.parse_args()
|
||||
if args.action == "status":
|
||||
print(json.dumps({"loaded": loaded(), "healthy": health()}))
|
||||
return
|
||||
if not args.target:
|
||||
parser.error("--target is required")
|
||||
runtime = Path.home() / "Library/Logs/NODEDC/MissionCore/map-gateway-link"
|
||||
agent = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
||||
if agent.is_symlink():
|
||||
raise ValueError("Refusing a symlink LaunchAgent")
|
||||
previous = agent.read_bytes() if agent.exists() else None
|
||||
if previous is not None and plistlib.loads(previous).get("Label") != LABEL:
|
||||
raise ValueError("Refusing a foreign LaunchAgent")
|
||||
desired = payload(args.target, runtime)
|
||||
plan = {"label": LABEL, "agent_path": str(agent), "target": args.target,
|
||||
"local_bind": f"127.0.0.1:{PORT}", "remote_bind": f"127.0.0.1:{PORT}",
|
||||
"current_sha256": digest(previous), "desired_sha256": digest(desired),
|
||||
"remote_mutation": False}
|
||||
if args.action == "plan":
|
||||
print(json.dumps(plan, indent=2))
|
||||
return
|
||||
if (args.expected_current_sha256 != digest(previous)
|
||||
or args.expected_desired_sha256 != digest(desired)):
|
||||
raise ValueError("Map Gateway link plan changed before apply")
|
||||
was_loaded = loaded()
|
||||
if previous == desired and was_loaded and health():
|
||||
print(json.dumps({**plan, "applied": False, "healthy": True}))
|
||||
return
|
||||
if not was_loaded:
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", PORT)) # Refuse an unrelated local listener.
|
||||
runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
agent.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if previous is not None:
|
||||
write_atomic(runtime / f"backup-{digest(previous)}.plist", previous)
|
||||
try:
|
||||
if was_loaded:
|
||||
unload()
|
||||
write_atomic(agent, desired)
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
|
||||
capture_output=True, timeout=20, check=True)
|
||||
deadline = time.monotonic() + 40
|
||||
while not health():
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Map Gateway link did not pass health acceptance")
|
||||
time.sleep(.5)
|
||||
except BaseException:
|
||||
unload()
|
||||
if previous is not None:
|
||||
write_atomic(agent, previous)
|
||||
if was_loaded:
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
|
||||
capture_output=True, timeout=20, check=True)
|
||||
else:
|
||||
agent.unlink(missing_ok=True)
|
||||
raise
|
||||
print(json.dumps({**plan, "applied": True, "healthy": True}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user