feat: add session-gated TG3 data collection
This commit is contained in:
73
tg3_data_collection/README.md
Normal file
73
tg3_data_collection/README.md
Normal 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 设置中。
|
||||
363
tg3_data_collection/data_get_sync.py
Executable file
363
tg3_data_collection/data_get_sync.py
Executable 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())
|
||||
149
tg3_data_collection/test_data_get_sync.py
Executable file
149
tg3_data_collection/test_data_get_sync.py
Executable 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()
|
||||
14
tg3_data_collection/tg3-data-get-sync.service
Normal file
14
tg3_data_collection/tg3-data-get-sync.service
Normal 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
|
||||
Reference in New Issue
Block a user