feat: add session-gated TG3 data collection

This commit is contained in:
LengedZhao
2026-08-10 15:54:15 +08:00
parent 9d25bc9bff
commit 6caad268b8
22 changed files with 4429 additions and 9 deletions

View File

@@ -0,0 +1,73 @@
# TG3 data episode sync
机器人只在 `/home/nvidia/tg3_data_collection/ready` 暴露已经收到 SIGINT、写完
`metadata.yaml`、通过 `ros2 bag info` 并生成 SHA-256 清单的 episode。本机服务用免密 SSH/rsync 复制到
`Data_Get/.incoming`,逐文件校验后再原子改名为 `Data_Get/<episode_id>`。中断的复制不会
显示成完成数据,也不会删除机器人上的备份。
数据只走本机到 Nvidia 的 SSH 链路,与公网或 EAI 本地 OmniSocket Hub 无关。当前默认:
```text
nvidia@192.168.41.2:/home/nvidia/tg3_data_collection/ready/
-> /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get/
```
先确认免密与依赖:
```bash
ssh -o BatchMode=yes nvidia@192.168.41.2 true
command -v rsync
ssh nvidia@192.168.41.2 command -v rsync
```
安装本机用户服务:
```bash
mkdir -p ~/.config/systemd/user
cp tg3_data_collection/tg3-data-get-sync.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now tg3-data-get-sync.service
```
状态:
```bash
systemctl --user --no-pager status tg3-data-get-sync.service
python3 -m json.tool Data_Get/sync_status.json
```
也可以只执行一次,便于首次部署验收:
```bash
python3 tg3_data_collection/data_get_sync.py --once
```
日常轮询不会每 2 秒重新读取并哈希全部历史 MCAP;首次原子发布时已经完成深度校验。
需要定期审计已有本地数据时单独执行(可能耗时较长):
```bash
python3 tg3_data_collection/data_get_sync.py --once --verify-existing
```
某个旧 episode 损坏会写入 `sync_status.json`,但不会阻止同一轮继续拉取其他新 episode。
一个完成目录至少包含:
```text
<episode_id>/
READY
manifest.json
bag/metadata.yaml
bag/*.mcap
ros2_bag.stdout.log
ros2_bag.stderr.log
```
同步端会再次核对 `READY`、episode ID、每个 MCAP/metadata 的长度和 SHA-256;远端
manifest 在传输中发生变化也会拒绝发布最终目录。`.incoming`、`sync_status.json` 和全部
episode 已由 `Data_Get/.gitignore` 排除。服务绝不自动删除机器人端 `ready/` 数据;确认
本机备份后如需清理,必须由操作者明确指定具体 episode,不能删除整个项目目录。
迁移后需要同步修改 service 中的本机项目路径、机器人 SSH 地址和机器人 ready 路径。
如果 Nvidia 的 `192.168.41.2` 改变,只改该 unit 的 `--remote`;它不在机器人
`config.toml` 或 OmniSocket Peer 设置中。

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""Pull completed TG3 data-collection episodes from the robot atomically."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shlex
import shutil
import signal
import subprocess
import time
from pathlib import Path
from typing import Any, Sequence
SAFE_EPISODE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def safe_episode_name(value: str) -> bool:
return bool(SAFE_EPISODE_NAME.fullmatch(value)) and value not in (".", "..")
def safe_relative_path(value: object) -> Path:
if not isinstance(value, str) or not value:
raise ValueError("manifest file path must be a non-empty string")
path = Path(value)
if path.is_absolute() or ".." in path.parts:
raise ValueError(f"unsafe manifest file path: {value!r}")
return path
def parse_manifest(raw: str, expected_episode: str) -> dict[str, Any]:
payload = json.loads(raw)
if not isinstance(payload, dict):
raise ValueError("manifest root must be an object")
if payload.get("state") != "complete":
raise ValueError("remote episode is not complete")
if payload.get("episode_id") != expected_episode:
raise ValueError("manifest episode_id does not match directory name")
files = payload.get("files")
if not isinstance(files, list) or not files:
raise ValueError("manifest files must be a non-empty list")
saw_mcap = False
for entry in files:
if not isinstance(entry, dict):
raise ValueError("manifest file entry must be an object")
relative = safe_relative_path(entry.get("path"))
saw_mcap = saw_mcap or relative.suffix == ".mcap"
size = entry.get("size")
digest = entry.get("sha256")
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
raise ValueError(f"invalid size for {relative}")
if not (
isinstance(digest, str)
and len(digest) == 64
and all(character in "0123456789abcdef" for character in digest)
):
raise ValueError(f"invalid sha256 for {relative}")
if not saw_mcap:
raise ValueError("manifest contains no MCAP file")
return payload
def validate_episode_dir(directory: Path, manifest: dict[str, Any]) -> None:
ready = directory / "READY"
if not ready.is_file() or ready.is_symlink():
raise ValueError("episode has no regular READY marker")
for entry in manifest["files"]:
relative = safe_relative_path(entry["path"])
path = directory / relative
if not path.is_file() or path.is_symlink():
raise ValueError(f"missing copied file: {relative}")
if path.stat().st_size != entry["size"]:
raise ValueError(f"size mismatch: {relative}")
if sha256_file(path) != entry["sha256"]:
raise ValueError(f"sha256 mismatch: {relative}")
class CommandRunner:
def run(
self,
command: Sequence[str],
*,
timeout: float,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
list(command),
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
class DataGetSync:
def __init__(
self,
*,
remote: str,
remote_ready: str,
destination: Path,
status_file: Path,
runner: CommandRunner | None = None,
ssh_timeout_s: float = 8.0,
reserve_bytes: int = 1_073_741_824,
verify_existing: bool = False,
) -> None:
self.remote = remote
self.remote_ready = remote_ready.rstrip("/")
self.destination = destination
self.status_file = status_file
self.runner = runner or CommandRunner()
self.ssh_timeout_s = ssh_timeout_s
self.reserve_bytes = reserve_bytes
self.verify_existing = verify_existing
self.stop_requested = False
self.started_at = time.time()
self.sync_count = 0
self.last_episode: str | None = None
self.last_error = ""
@property
def ssh_base(self) -> list[str]:
return [
"ssh",
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={max(1, int(self.ssh_timeout_s))}",
self.remote,
]
def request_stop(self, _signum: int, _frame: object) -> None:
self.stop_requested = True
def _write_status(self, state: str) -> None:
atomic_write_json(
self.status_file,
{
"state": state,
"remote": self.remote,
"remote_ready": self.remote_ready,
"destination": str(self.destination),
"sync_count": self.sync_count,
"last_episode": self.last_episode,
"last_error": self.last_error,
"uptime_s": round(time.time() - self.started_at, 1),
"updated_unix_s": time.time(),
},
)
def _remote_command(self, command: str) -> str:
result = self.runner.run(
[*self.ssh_base, command], timeout=self.ssh_timeout_s + 2.0
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
raise RuntimeError(f"remote command failed: {detail}")
return result.stdout
def list_remote_episodes(self) -> list[str]:
root = shlex.quote(self.remote_ready)
output = self._remote_command(
f"find {root} -mindepth 1 -maxdepth 1 -type d -printf '%f\\n'"
)
names = sorted({line.strip() for line in output.splitlines() if line.strip()})
unsafe = [name for name in names if not safe_episode_name(name)]
if unsafe:
raise RuntimeError(f"remote returned unsafe episode names: {unsafe!r}")
return names
def get_remote_manifest(self, episode: str) -> dict[str, Any]:
if not safe_episode_name(episode):
raise ValueError(f"unsafe episode name: {episode!r}")
episode_path = f"{self.remote_ready}/{episode}"
manifest_path = f"{episode_path}/manifest.json"
ready_path = f"{episode_path}/READY"
raw = self._remote_command(
f"test -f {shlex.quote(ready_path)} && cat {shlex.quote(manifest_path)}"
)
return parse_manifest(raw, episode)
def _enough_local_space(self, manifest: dict[str, Any]) -> bool:
required = sum(int(entry["size"]) for entry in manifest["files"])
free = shutil.disk_usage(self.destination).free
return free >= required + self.reserve_bytes
def sync_episode(self, episode: str) -> bool:
if not safe_episode_name(episode):
raise ValueError(f"unsafe episode name: {episode!r}")
final = self.destination / episode
if final.exists():
manifest_path = final / "manifest.json"
ready_path = final / "READY"
if (
not manifest_path.is_file()
or manifest_path.is_symlink()
or not ready_path.is_file()
or ready_path.is_symlink()
):
raise RuntimeError(f"existing destination is incomplete: {final}")
manifest = parse_manifest(manifest_path.read_text(encoding="utf-8"), episode)
# The first transfer already hashed every listed artifact before
# the atomic rename. Re-reading all historical MCAP files every
# two seconds would eventually saturate the workstation disk.
if self.verify_existing:
validate_episode_dir(final, manifest)
return False
manifest = self.get_remote_manifest(episode)
if not self._enough_local_space(manifest):
raise RuntimeError("not enough local disk space for episode")
incoming = self.destination / ".incoming" / episode
incoming.mkdir(parents=True, exist_ok=True)
remote_source = f"{self.remote}:{self.remote_ready}/{episode}/"
ssh_transport = (
"ssh -o BatchMode=yes "
f"-o ConnectTimeout={max(1, int(self.ssh_timeout_s))}"
)
result = self.runner.run(
[
"rsync",
"-a",
"--partial",
"--protect-args",
"-e",
ssh_transport,
remote_source,
str(incoming) + "/",
],
timeout=24 * 60 * 60,
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
raise RuntimeError(f"rsync failed: {detail}")
copied_manifest_path = incoming / "manifest.json"
if not copied_manifest_path.is_file():
raise RuntimeError("copied episode has no manifest.json")
copied_manifest = parse_manifest(
copied_manifest_path.read_text(encoding="utf-8"), episode
)
if copied_manifest != manifest:
raise RuntimeError("remote manifest changed during transfer")
validate_episode_dir(incoming, copied_manifest)
os.replace(incoming, final)
self.sync_count += 1
self.last_episode = episode
return True
def run_once(self) -> int:
self.destination.mkdir(parents=True, exist_ok=True)
(self.destination / ".incoming").mkdir(parents=True, exist_ok=True)
copied = 0
errors: list[str] = []
try:
episodes = self.list_remote_episodes()
for episode in episodes:
try:
if self.sync_episode(episode):
copied += 1
except Exception as exc:
# One damaged historical episode must not starve newer
# ready data. Keep its error visible and continue.
errors.append(f"{episode}: {exc}")
if errors:
raise RuntimeError("; ".join(errors))
self.last_error = ""
self._write_status("idle")
except Exception as exc:
self.last_error = str(exc)
self._write_status("error")
raise
return copied
def run_forever(self, poll_seconds: float) -> None:
self.destination.mkdir(parents=True, exist_ok=True)
while not self.stop_requested:
try:
self.run_once()
except Exception:
pass
deadline = time.monotonic() + poll_seconds
while not self.stop_requested and time.monotonic() < deadline:
time.sleep(min(0.2, max(0.0, deadline - time.monotonic())))
self._write_status("stopped")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--remote", default="nvidia@192.168.41.2")
parser.add_argument(
"--remote-ready", default="/home/nvidia/tg3_data_collection/ready"
)
parser.add_argument(
"--destination",
type=Path,
default=Path(
"/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get"
),
)
parser.add_argument("--status-file", type=Path)
parser.add_argument("--poll-seconds", type=float, default=2.0)
parser.add_argument(
"--verify-existing",
action="store_true",
help="rehash already published local episodes (slow; intended for audits)",
)
parser.add_argument("--once", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.poll_seconds <= 0.0:
raise SystemExit("--poll-seconds must be positive")
destination = args.destination.resolve()
status_file = args.status_file or destination / "sync_status.json"
syncer = DataGetSync(
remote=args.remote,
remote_ready=args.remote_ready,
destination=destination,
status_file=status_file,
verify_existing=args.verify_existing,
)
signal.signal(signal.SIGINT, syncer.request_stop)
signal.signal(signal.SIGTERM, syncer.request_stop)
if args.once:
try:
syncer.run_once()
except Exception as exc:
print(f"data sync failed: {exc}")
return 1
return 0
syncer.run_forever(args.poll_seconds)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import json
import tempfile
import unittest
from pathlib import Path
from data_get_sync import (
DataGetSync,
parse_manifest,
safe_episode_name,
safe_relative_path,
validate_episode_dir,
)
class DataGetSyncTests(unittest.TestCase):
def test_safe_episode_name(self) -> None:
self.assertTrue(safe_episode_name("episode_20260810T120000000_deadbeef"))
self.assertFalse(safe_episode_name("../escape"))
self.assertFalse(safe_episode_name("bad/name"))
self.assertFalse(safe_episode_name(""))
def test_safe_relative_path(self) -> None:
self.assertEqual(safe_relative_path("bag/metadata.yaml"), Path("bag/metadata.yaml"))
for invalid in ("", "/etc/passwd", "../escape", "bag/../../escape", None):
with self.subTest(invalid=invalid):
with self.assertRaises(ValueError):
safe_relative_path(invalid)
def _episode(self, root: Path, name: str) -> tuple[Path, dict]:
episode = root / name
(episode / "bag").mkdir(parents=True)
mcap = episode / "bag" / "bag_0.mcap"
metadata = episode / "bag" / "metadata.yaml"
mcap.write_bytes(b"mcap-data")
metadata.write_text("rosbag2_bagfile_information: {}\n", encoding="utf-8")
files = []
for path in (mcap, metadata):
raw = path.read_bytes()
files.append(
{
"path": str(path.relative_to(episode)),
"size": len(raw),
"sha256": hashlib.sha256(raw).hexdigest(),
}
)
manifest = {"state": "complete", "episode_id": name, "files": files}
(episode / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(episode / "READY").write_text("ready\n", encoding="ascii")
return episode, manifest
def test_parse_and_validate_episode(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
name = "episode_20260810T120000000_deadbeef"
episode, manifest = self._episode(Path(temporary), name)
parsed = parse_manifest(json.dumps(manifest), name)
validate_episode_dir(episode, parsed)
def test_manifest_requires_complete_matching_episode_and_mcap(self) -> None:
digest = "0" * 64
base = {
"state": "complete",
"episode_id": "episode_ok",
"files": [{"path": "bag/bag_0.mcap", "size": 1, "sha256": digest}],
}
with self.assertRaises(ValueError):
parse_manifest(json.dumps({**base, "state": "active"}), "episode_ok")
with self.assertRaises(ValueError):
parse_manifest(json.dumps(base), "episode_other")
no_mcap = {
**base,
"files": [{"path": "bag/metadata.yaml", "size": 1, "sha256": digest}],
}
with self.assertRaises(ValueError):
parse_manifest(json.dumps(no_mcap), "episode_ok")
def test_validate_detects_tampering(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
name = "episode_20260810T120000000_deadbeef"
episode, manifest = self._episode(Path(temporary), name)
(episode / "bag" / "bag_0.mcap").write_bytes(b"changed")
with self.assertRaises(ValueError):
validate_episode_dir(episode, manifest)
def test_validate_requires_regular_ready_marker(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
name = "episode_20260810T120000000_deadbeef"
episode, manifest = self._episode(Path(temporary), name)
(episode / "READY").unlink()
with self.assertRaises(ValueError):
validate_episode_dir(episode, manifest)
def test_existing_episode_skips_expensive_hash_unless_requested(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
name = "episode_20260810T120000000_deadbeef"
episode, _manifest = self._episode(root, name)
(episode / "bag" / "bag_0.mcap").write_bytes(b"changed")
fast = DataGetSync(
remote="unused",
remote_ready="/unused",
destination=root,
status_file=root / "status.json",
)
self.assertFalse(fast.sync_episode(name))
deep = DataGetSync(
remote="unused",
remote_ready="/unused",
destination=root,
status_file=root / "status.json",
verify_existing=True,
)
with self.assertRaises(ValueError):
deep.sync_episode(name)
def test_bad_old_episode_does_not_starve_newer_episode(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
class ProbeSync(DataGetSync):
def __init__(self) -> None:
super().__init__(
remote="unused",
remote_ready="/unused",
destination=root,
status_file=root / "status.json",
)
self.seen: list[str] = []
def list_remote_episodes(self) -> list[str]:
return ["episode_bad", "episode_new"]
def sync_episode(self, episode: str) -> bool:
self.seen.append(episode)
if episode == "episode_bad":
raise RuntimeError("damaged")
return True
syncer = ProbeSync()
with self.assertRaises(RuntimeError):
syncer.run_once()
self.assertEqual(syncer.seen, ["episode_bad", "episode_new"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,14 @@
[Unit]
Description=TG3 completed data episode sync to Data_Get
[Service]
Type=simple
WorkingDirectory=/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop
ExecStart=/usr/bin/python3 /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/tg3_data_collection/data_get_sync.py --remote nvidia@192.168.41.2 --remote-ready /home/nvidia/tg3_data_collection/ready --destination /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get --poll-seconds 2
Restart=always
RestartSec=2
KillSignal=SIGINT
TimeoutStopSec=10
[Install]
WantedBy=default.target