Files
TG3/tg3_omnisocket_transport/test_session_gate.py

996 lines
38 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import copy
import importlib.util
import json
from pathlib import Path
import sys
from threading import Event
from types import ModuleType
import unittest
from unittest import mock
fake_omnisocket = ModuleType("omnisocket")
fake_omnisocket.CONTROL_DEFAULTS = {}
fake_omnisocket.MSG_TYPE_ERROR = 5
fake_omnisocket.Session = object
sys.modules.setdefault("omnisocket", fake_omnisocket)
module_path = Path(__file__).with_name("omnisocket_xtele_sender.py")
spec = importlib.util.spec_from_file_location("omnisocket_xtele_sender", module_path)
assert spec is not None and spec.loader is not None
sender_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sender_module)
def args() -> argparse.Namespace:
return argparse.Namespace(
server="127.0.0.1:14049",
peer_id="sender",
target_peer="robot",
zmq_endpoint="tcp://127.0.0.1:5003",
cmd_zmq_endpoint="",
cmd_max_age_s=0.25,
source_timeout_s=0.25,
max_feedback_age_ms=500.0,
max_pending_frames=100,
start_stop_hold_s=3.0,
combo_release_s=0.5,
start_marker_frames=50,
status_file="/tmp/tg3_sender_test_status.json",
)
def frame(pressed: bool) -> dict[str, object]:
value = 1 if pressed else 0
return {
"button": {
"left": [0, 0, value],
"right": [0, 0, value],
}
}
class SessionGateTest(unittest.TestCase):
class FakeSession:
def __init__(
self,
responses: list[tuple[str, int, bytes]] | None = None,
) -> None:
self.responses = list(responses or [])
self.sent: list[tuple[str, bytes]] = []
self.closed = False
@staticmethod
def stats() -> dict[str, int]:
return {"connected": 1, "registered": 1}
@staticmethod
def kcp_stats() -> dict[str, int]:
return {
"snd_queue": 0,
"snd_buffer": 0,
"last_feedback_age_ms": 0,
}
def send(self, *, to: str, data: bytes) -> None:
self.sent.append((to, data))
def recv(self, timeout_ms: int) -> tuple[str, int, bytes] | None:
self.assert_zero_timeout(timeout_ms)
if self.responses:
return self.responses.pop(0)
return None
@staticmethod
def assert_zero_timeout(timeout_ms: int) -> None:
if timeout_ms != 0:
raise AssertionError(f"unexpected receive timeout: {timeout_ms}")
def close(self) -> None:
self.closed = True
class BlockingConnectSession(FakeSession):
def __init__(
self,
entered: Event,
release: Event,
failure: OSError | None = None,
) -> None:
super().__init__()
self.entered = entered
self.release = release
self.failure = failure
self.connect_args: tuple[str, str, dict[str, object]] | None = None
def connect(
self,
*,
server_addr: str,
peer_id: str,
**options: object,
) -> None:
self.connect_args = (server_addr, peer_id, options)
self.entered.set()
if not self.release.wait(timeout=1.0):
raise RuntimeError("test did not release blocked connect")
if self.failure is not None:
raise self.failure
def setUp(self) -> None:
self.sender = sender_module.XteleSender(args())
def stable_release(self, started_at: float) -> float:
self.assertIsNone(
self.sender._update_teleop_gate(started_at, frame(False))
)
finished_at = started_at + 0.51
self.assertIsNone(
self.sender._update_teleop_gate(finished_at, frame(False))
)
self.assertFalse(self.sender.require_combo_release)
return finished_at
def create_pending_start(self, started_at: float = 0.0) -> float:
released_at = self.stable_release(started_at)
self.assertIsNone(
self.sender._update_teleop_gate(released_at + 0.01, frame(True))
)
pending_at = released_at + 3.02
self.assertEqual(
self.sender._update_teleop_gate(pending_at, frame(True)),
"start_pending",
)
self.assertTrue(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
return pending_at
def cancel_pending_from_latest_frame(self) -> None:
if (
self.sender.start_pending
and self.sender.latest_combo_state is not True
):
reason = (
"released"
if self.sender.latest_combo_state is False
else "malformed"
)
self.sender._cancel_pending_start(reason)
def attach_session(
self,
responses: list[tuple[str, int, bytes]] | None = None,
) -> FakeSession:
session = self.FakeSession(responses)
self.sender.session = session
self.sender.session_connected_at = 100.0
self.sender.counters["connected"] = 1
return session
def test_boot_requires_stable_release_before_pending_start(self) -> None:
self.assertIsNone(self.sender._update_teleop_gate(0.0, frame(True)))
self.assertIsNone(self.sender._update_teleop_gate(4.0, frame(True)))
released_at = self.stable_release(5.0)
self.assertIsNone(
self.sender._update_teleop_gate(released_at + 0.01, frame(True))
)
self.assertEqual(
self.sender._update_teleop_gate(released_at + 3.02, frame(True)),
"start_pending",
)
self.assertTrue(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertEqual(self.sender.counters["teleop_start_requests"], 1)
self.assertEqual(self.sender.counters["teleop_starts"], 0)
self.assertIsNotNone(self.sender.teleop_session_id)
self.assertTrue(
self.sender._pending_connect_ready(released_at + 3.02)
)
def test_boot_malformed_buttons_cannot_clear_release_requirement(self) -> None:
malformed = frame(False)
malformed["button"] = {
"left": [0, 0, 0],
"right": [0, 0, "0"],
}
self.assertIsNone(self.sender._update_teleop_gate(0.0, frame(False)))
self.assertEqual(self.sender.combo_release_started_at, 0.0)
self.assertIsNone(self.sender._update_teleop_gate(0.49, malformed))
self.assertTrue(self.sender.require_combo_release)
self.assertIsNone(self.sender.combo_release_started_at)
self.assertIsNone(self.sender.latest_combo_state)
# The release timer must restart after malformed input; time before the
# malformed frame cannot be accumulated toward re-arming.
self.assertIsNone(self.sender._update_teleop_gate(0.50, frame(False)))
self.assertIsNone(self.sender._update_teleop_gate(0.99, frame(False)))
self.assertTrue(self.sender.require_combo_release)
self.assertIsNone(self.sender._update_teleop_gate(1.01, frame(False)))
self.assertFalse(self.sender.require_combo_release)
def test_start_stop_parser_accepts_only_explicit_boolean_buttons(self) -> None:
self.assertTrue(self.sender._start_stop_pressed(frame(True)))
self.assertFalse(self.sender._start_stop_pressed(frame(False)))
for invalid in (1.0, "1", None, 2, -1):
with self.subTest(value=invalid):
malformed = frame(True)
malformed["button"] = {
"left": [0, 0, invalid],
"right": [0, 0, 1],
}
self.assertIsNone(
self.sender._start_stop_pressed(malformed)
)
def test_single_false_frame_cannot_rearm_stop(self) -> None:
pending_at = self.create_pending_start()
self.sender._commit_pending_start_sent(pending_at + 0.01)
self.assertIsNone(
self.sender._update_teleop_gate(pending_at + 0.02, frame(False))
)
self.assertIsNone(
self.sender._update_teleop_gate(pending_at + 0.03, frame(True))
)
self.assertIsNone(
self.sender._update_teleop_gate(pending_at + 4.00, frame(True))
)
self.assertTrue(self.sender.teleop_active)
self.assertTrue(self.sender.require_combo_release)
def test_stable_release_allows_separate_stop_hold(self) -> None:
pending_at = self.create_pending_start()
self.sender._commit_pending_start_sent(pending_at + 0.01)
second_release = self.stable_release(pending_at + 0.02)
self.sender._update_teleop_gate(second_release + 0.01, frame(True))
self.assertEqual(
self.sender._update_teleop_gate(second_release + 3.02, frame(True)),
"stop",
)
self.assertFalse(self.sender.teleop_active)
self.assertEqual(self.sender.counters["teleop_starts"], 1)
self.assertEqual(self.sender.counters["teleop_stops"], 1)
def test_cancelled_start_requires_release_before_a_new_hold(self) -> None:
pending_at = self.create_pending_start()
first_session_id = self.sender.teleop_session_id
self.sender._cancel_pending_start("Hub offline")
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertTrue(self.sender.require_combo_release)
self.assertEqual(self.sender.last_start_failure, "Hub offline")
self.assertIsNone(self.sender.teleop_session_id)
# A consumed hold cannot silently become another pending request while
# the operator keeps Z+C pressed, regardless of elapsed time.
self.assertIsNone(
self.sender._update_teleop_gate(
pending_at + 10.0, frame(True)
)
)
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender._pending_connect_ready(pending_at + 10.0))
# A stable release followed by a new complete hold creates a distinct
# logical session which can make one new connection attempt.
released_at = self.stable_release(pending_at + 10.01)
self.assertIsNone(
self.sender._update_teleop_gate(
released_at + 0.01, frame(True)
)
)
self.assertEqual(
self.sender._update_teleop_gate(
released_at + 3.02, frame(True)
),
"start_pending",
)
self.assertTrue(self.sender.start_pending)
self.assertIsNotNone(self.sender.teleop_session_id)
self.assertNotEqual(self.sender.teleop_session_id, first_session_id)
self.assertEqual(self.sender.counters["teleop_start_requests"], 2)
self.assertEqual(self.sender.counters["teleop_starts"], 0)
def test_pending_start_release_cancels_and_closes_session(self) -> None:
pending_at = self.create_pending_start()
session = self.attach_session()
self.sender.start_wait_fresh_after_connect = True
self.assertIsNone(
self.sender._update_teleop_gate(pending_at + 0.01, frame(False))
)
self.cancel_pending_from_latest_frame()
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertIsNone(self.sender.teleop_session_id)
self.assertIsNone(self.sender.session)
self.assertTrue(session.closed)
self.assertEqual(session.sent, [])
self.assertEqual(self.sender.counters["start_pending_cancels"], 1)
self.assertTrue(self.sender.require_combo_release)
self.assertFalse(self.sender._pending_connect_ready(pending_at + 10.0))
def test_pending_start_malformed_button_state_cancels(self) -> None:
pending_at = self.create_pending_start()
malformed = frame(True)
malformed["button"] = {"left": [0, 0], "right": [0, 0, 1]}
self.assertIsNone(
self.sender._update_teleop_gate(pending_at + 0.01, malformed)
)
self.cancel_pending_from_latest_frame()
self.assertFalse(self.sender.start_pending)
self.assertIsNone(self.sender.latest_combo_state)
self.assertEqual(self.sender.last_start_failure, "malformed")
self.assertEqual(self.sender.counters["start_pending_cancels"], 1)
def test_pending_freshness_waits_then_accepts_advancing_timestamp(self) -> None:
self.create_pending_start()
self.sender.start_wait_fresh_after_connect = True
first = frame(True)
first["timestamp"] = 100.0
second = frame(True)
second["timestamp"] = 100.01
self.assertEqual(
self.sender._check_pending_start_freshness(first),
("wait", ""),
)
self.assertEqual(self.sender.start_fresh_barrier_timestamp, 100.0)
self.assertTrue(self.sender.start_wait_fresh_after_connect)
self.assertFalse(self.sender.teleop_active)
self.assertEqual(
self.sender._check_pending_start_freshness(second),
("ready", ""),
)
self.assertIsNone(self.sender.start_fresh_barrier_timestamp)
self.assertFalse(self.sender.start_wait_fresh_after_connect)
self.assertTrue(self.sender.start_pending)
self.assertEqual(self.sender.counters["teleop_starts"], 0)
def test_pending_freshness_missing_timestamp_cancels(self) -> None:
self.create_pending_start()
session = self.attach_session()
self.sender.start_wait_fresh_after_connect = True
outcome, reason = self.sender._check_pending_start_freshness(
frame(True)
)
self.assertEqual(outcome, "cancel")
self.assertIn("fresh xTELE timestamp", reason)
self.sender._cancel_pending_start(reason)
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertTrue(session.closed)
self.assertEqual(session.sent, [])
def test_pending_freshness_repeated_timestamp_waits_for_later_advance(
self,
) -> None:
self.create_pending_start()
self.sender.start_wait_fresh_after_connect = True
first = frame(True)
first["timestamp"] = 100.0
repeated = frame(True)
repeated["timestamp"] = 100.0
advanced = frame(True)
advanced["timestamp"] = 100.01
self.assertEqual(
self.sender._check_pending_start_freshness(first),
("wait", ""),
)
self.assertEqual(
self.sender._check_pending_start_freshness(repeated),
("wait", ""),
)
self.assertEqual(self.sender.start_fresh_barrier_timestamp, 100.0)
self.assertTrue(self.sender.start_wait_fresh_after_connect)
self.assertTrue(self.sender.start_pending)
self.assertEqual(
self.sender._check_pending_start_freshness(advanced),
("ready", ""),
)
self.assertIsNone(self.sender.start_fresh_barrier_timestamp)
self.assertFalse(self.sender.start_wait_fresh_after_connect)
self.assertTrue(self.sender.start_pending)
def test_pending_freshness_backwards_timestamp_cancels(self) -> None:
self.create_pending_start()
session = self.attach_session()
self.sender.start_wait_fresh_after_connect = True
first = frame(True)
first["timestamp"] = 100.0
backwards = frame(True)
backwards["timestamp"] = 99.99
self.assertEqual(
self.sender._check_pending_start_freshness(first),
("wait", ""),
)
outcome, reason = self.sender._check_pending_start_freshness(backwards)
self.assertEqual(outcome, "cancel")
self.assertIn("moved backwards", reason)
self.sender._cancel_pending_start(reason)
self.assertFalse(self.sender.start_pending)
self.assertTrue(session.closed)
self.assertEqual(session.sent, [])
def test_background_connect_success_is_adopted_as_pending(self) -> None:
self.create_pending_start()
entered = Event()
release = Event()
session = self.BlockingConnectSession(entered, release)
with (
mock.patch.object(sender_module, "Session", return_value=session),
mock.patch.object(self.sender, "write_status"),
):
self.assertTrue(self.sender._start_connect_attempt())
self.assertTrue(entered.wait(timeout=1.0))
self.assertTrue(self.sender._connect_inflight())
thread = self.sender.connect_thread
self.assertIsNotNone(thread)
release.set()
thread.join(timeout=1.0) # type: ignore[union-attr]
self.assertFalse(thread.is_alive()) # type: ignore[union-attr]
self.assertEqual(
self.sender._poll_connect_result(50.0), "connected"
)
self.assertIs(self.sender.session, session)
self.assertFalse(session.closed)
self.assertTrue(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertTrue(self.sender.start_wait_fresh_after_connect)
self.assertIsNone(self.sender.start_fresh_barrier_timestamp)
self.assertEqual(self.sender.counters["connected"], 1)
self.assertEqual(self.sender.counters["reconnects"], 1)
self.assertEqual(
session.connect_args,
(self.sender.args.server, self.sender.args.peer_id, {}),
)
self.sender.close_session()
def test_release_during_connect_discards_and_closes_late_result(self) -> None:
pending_at = self.create_pending_start()
entered = Event()
release = Event()
session = self.BlockingConnectSession(entered, release)
with mock.patch.object(sender_module, "Session", return_value=session):
self.assertTrue(self.sender._start_connect_attempt())
self.assertTrue(entered.wait(timeout=1.0))
thread = self.sender.connect_thread
self.assertIsNotNone(thread)
self.assertIsNone(
self.sender._update_teleop_gate(
pending_at + 0.01, frame(False)
)
)
self.cancel_pending_from_latest_frame()
self.assertFalse(self.sender.start_pending)
release.set()
thread.join(timeout=1.0) # type: ignore[union-attr]
self.assertFalse(thread.is_alive()) # type: ignore[union-attr]
self.assertEqual(
self.sender._poll_connect_result(pending_at + 0.02),
"discarded",
)
self.assertTrue(session.closed)
self.assertIsNone(self.sender.session)
self.assertFalse(self.sender.teleop_active)
self.assertIsNone(self.sender.teleop_session_id)
self.assertEqual(self.sender.counters["connected"], 0)
self.assertEqual(self.sender.counters["teleop_starts"], 0)
self.assertEqual(self.sender.counters["start_pending_cancels"], 1)
def test_background_connect_failure_consumes_hold_without_retry(self) -> None:
pending_at = self.create_pending_start()
first_session_id = self.sender.teleop_session_id
entered = Event()
release = Event()
session = self.BlockingConnectSession(
entered,
release,
OSError("connection refused"),
)
with (
mock.patch.object(sender_module, "Session", return_value=session),
mock.patch.object(self.sender, "write_status"),
):
# run() owns this counter; mirror its one increment around the
# lower-level worker call used by this unit test.
self.sender.counters["start_connect_attempts"] += 1
self.assertTrue(self.sender._start_connect_attempt())
self.assertTrue(entered.wait(timeout=1.0))
thread = self.sender.connect_thread
self.assertIsNotNone(thread)
release.set()
thread.join(timeout=1.0) # type: ignore[union-attr]
self.assertFalse(thread.is_alive()) # type: ignore[union-attr]
self.assertEqual(self.sender._poll_connect_result(20.0), "failed")
self.assertTrue(session.closed)
self.assertEqual(session.sent, [])
self.assertIsNone(self.sender.session)
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertIsNone(self.sender.teleop_session_id)
self.assertTrue(self.sender.require_combo_release)
self.assertEqual(self.sender.counters["start_connect_failures"], 1)
self.assertEqual(self.sender.counters["start_connect_attempts"], 1)
self.assertEqual(self.sender.counters["teleop_starts"], 0)
self.assertEqual(self.sender.counters["frames_sent"], 0)
self.assertIn("connection refused", self.sender.last_start_failure)
self.assertFalse(self.sender._pending_connect_ready(100.0))
# Keeping the failed Z+C hold pressed cannot create another request or
# connection attempt after the Hub later becomes available.
self.assertIsNone(
self.sender._update_teleop_gate(25.0, frame(True))
)
self.assertFalse(self.sender.start_pending)
self.assertEqual(self.sender.counters["teleop_start_requests"], 1)
self.assertEqual(self.sender.counters["start_connect_attempts"], 1)
released_at = self.stable_release(25.01)
self.assertIsNone(
self.sender._update_teleop_gate(
released_at + 0.01, frame(True)
)
)
self.assertEqual(
self.sender._update_teleop_gate(
released_at + 3.02, frame(True)
),
"start_pending",
)
self.assertTrue(self.sender.start_pending)
self.assertNotEqual(self.sender.teleop_session_id, first_session_id)
self.assertEqual(self.sender.counters["teleop_start_requests"], 2)
self.assertTrue(self.sender._pending_connect_ready(released_at + 3.02))
def test_session_constructor_failure_consumes_hold_without_retry(self) -> None:
self.create_pending_start()
constructor_called = Event()
def broken_session_constructor() -> object:
constructor_called.set()
raise RuntimeError("Session constructor failed")
with (
mock.patch.object(
sender_module,
"Session",
side_effect=broken_session_constructor,
),
mock.patch.object(self.sender, "write_status"),
):
self.assertTrue(self.sender._start_connect_attempt())
self.assertTrue(constructor_called.wait(timeout=1.0))
thread = self.sender.connect_thread
self.assertIsNotNone(thread)
thread.join(timeout=1.0) # type: ignore[union-attr]
self.assertFalse(thread.is_alive()) # type: ignore[union-attr]
self.assertEqual(self.sender._poll_connect_result(30.0), "failed")
self.assertIsNone(self.sender.session)
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertIsNone(self.sender.teleop_session_id)
self.assertTrue(self.sender.require_combo_release)
self.assertEqual(self.sender.counters["start_connect_failures"], 1)
self.assertIn("Session constructor failed", self.sender.last_start_failure)
self.assertEqual(self.sender.counters["teleop_starts"], 0)
self.assertFalse(self.sender._pending_connect_ready(300.0))
def test_successful_start_commit_activates_once(self) -> None:
pending_at = self.create_pending_start()
self.sender._commit_pending_start_sent(pending_at + 0.01)
self.assertFalse(self.sender.start_pending)
self.assertTrue(self.sender.teleop_active)
self.assertEqual(self.sender.counters["teleop_starts"], 1)
self.assertEqual(self.sender.counters["teleop_start_requests"], 1)
def test_unknown_target_is_an_ordinary_remote_error_and_aborts(self) -> None:
pending_at = self.create_pending_start()
self.sender._commit_pending_start_sent(pending_at + 0.01)
session = self.attach_session(
[("hub", fake_omnisocket.MSG_TYPE_ERROR, b"unknown target: robot")]
)
self.sender._drain_responses()
self.assertTrue(session.closed)
self.assertIsNone(self.sender.session)
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertIsNone(self.sender.teleop_session_id)
self.assertTrue(self.sender.require_combo_release)
self.assertEqual(self.sender.counters["teleop_aborts"], 1)
self.assertEqual(self.sender.counters["teleop_starts"], 1)
self.assertIn("unknown target: robot", self.sender.last_error)
def test_other_remote_error_also_aborts(self) -> None:
pending_at = self.create_pending_start()
self.sender._commit_pending_start_sent(pending_at + 0.01)
session = self.attach_session(
[("hub", fake_omnisocket.MSG_TYPE_ERROR, b"route unavailable")]
)
self.sender._drain_responses()
self.assertTrue(session.closed)
self.assertFalse(self.sender.start_pending)
self.assertFalse(self.sender.teleop_active)
self.assertEqual(self.sender.counters["teleop_aborts"], 1)
self.assertIn("route unavailable", self.sender.last_error)
def test_source_clock_allows_adjacent_repeat_under_timeout(self) -> None:
first = frame(False)
first["timestamp"] = 2507
repeated = frame(False)
repeated["timestamp"] = 2507
self.assertEqual(
self.sender._check_source_clock(10.0, first),
(True, ""),
)
self.assertEqual(
self.sender._check_source_clock(10.24, repeated),
(True, ""),
)
self.assertEqual(self.sender.last_source_timestamp, 2507.0)
self.assertEqual(self.sender.last_source_timestamp_advanced_at, 10.0)
def test_source_clock_rejects_persistent_freeze_over_timeout(self) -> None:
first = frame(False)
first["timestamp"] = 2517
repeated = frame(False)
repeated["timestamp"] = 2517
self.assertEqual(
self.sender._check_source_clock(10.0, first),
(True, ""),
)
self.assertEqual(
self.sender._check_source_clock(10.24, repeated),
(True, ""),
)
clock_ok, reason = self.sender._check_source_clock(10.251, repeated)
self.assertFalse(clock_ok)
self.assertIn("stopped advancing", reason)
self.assertEqual(self.sender.last_source_timestamp, 2517.0)
def test_source_clock_rejects_backwards_timestamp_while_active(self) -> None:
first = frame(False)
first["timestamp"] = 3000
backwards = frame(False)
backwards["timestamp"] = 2999
self.assertEqual(
self.sender._check_source_clock(10.0, first),
(True, ""),
)
self.sender.teleop_active = True
clock_ok, reason = self.sender._check_source_clock(10.01, backwards)
self.assertFalse(clock_ok)
self.assertIn("moved backwards", reason)
self.assertEqual(self.sender.last_source_timestamp, 3000.0)
self.assertEqual(self.sender.counters["source_timestamp_resets"], 0)
def test_source_clock_idle_backwards_resets_and_requires_release(self) -> None:
first = frame(False)
first["timestamp"] = 4000
restarted = frame(True)
restarted["timestamp"] = 10
self.assertEqual(
self.sender._check_source_clock(10.0, first),
(True, ""),
)
self.sender.require_combo_release = False
self.sender.combo_started_at = 10.01
self.sender.combo_release_started_at = 10.02
self.sender.latest_combo_state = True
self.assertEqual(
self.sender._check_source_clock(10.03, restarted),
(True, ""),
)
self.assertEqual(self.sender.last_source_timestamp, 10.0)
self.assertEqual(self.sender.last_source_timestamp_advanced_at, 10.03)
self.assertTrue(self.sender.require_combo_release)
self.assertIsNone(self.sender.combo_started_at)
self.assertIsNone(self.sender.combo_release_started_at)
self.assertIsNone(self.sender.latest_combo_state)
self.assertEqual(self.sender.counters["source_timestamp_resets"], 1)
def test_source_clock_rejects_missing_timestamp(self) -> None:
clock_ok, reason = self.sender._check_source_clock(10.0, frame(False))
self.assertFalse(clock_ok)
self.assertIn("missing or malformed", reason)
self.assertIsNone(self.sender.last_source_timestamp)
def test_finish_stop_session_clears_state_and_closes_session(self) -> None:
session = self.attach_session()
self.sender.teleop_active = True
self.sender.teleop_session_id = "active-session"
self.sender.teleop_session_seq = 42
self.sender.start_markers_remaining = 7
self.sender._finish_stop_session()
self.assertFalse(self.sender.teleop_active)
self.assertIsNone(self.sender.teleop_session_id)
self.assertEqual(self.sender.teleop_session_seq, 0)
self.assertEqual(self.sender.start_markers_remaining, 0)
self.assertIsNone(self.sender.session)
self.assertTrue(session.closed)
self.assertEqual(session.sent, [])
self.assertEqual(self.sender.counters["connected"], 0)
def test_untrusted_transport_metadata_is_overwritten(self) -> None:
data = frame(False)
data["tg3_transport"] = {
"protocol_version": 999,
"session_id": "forged",
"session_seq": 999,
"session_state": "stop",
"stop_reason": "forged",
}
payload, merged = self.sender._build_payload(
data,
None,
"a" * 32,
7,
"start",
)
self.assertFalse(merged)
metadata = json.loads(payload)["tg3_transport"]
self.assertEqual(metadata["protocol_version"], 2)
self.assertEqual(metadata["session_id"], "a" * 32)
self.assertEqual(metadata["session_seq"], 7)
self.assertEqual(metadata["session_state"], "start")
self.assertNotIn("stop_reason", metadata)
def test_stale_or_backlogged_transport_is_rejected(self) -> None:
class FakeSession:
def __init__(self, feedback_age_ms: int, pending: int) -> None:
self.feedback_age_ms = feedback_age_ms
self.pending = pending
@staticmethod
def stats() -> dict[str, int]:
return {"connected": 1, "registered": 1}
def kcp_stats(self) -> dict[str, int]:
return {
"snd_queue": self.pending,
"snd_buffer": 0,
"last_feedback_age_ms": self.feedback_age_ms,
}
self.sender.session_connected_at = 0.0
self.sender.session = FakeSession(600, 0)
self.assertIn(
"feedback stale", self.sender._session_unhealthy_reason() or ""
)
self.sender.session = FakeSession(1, 101)
self.assertIn(
"pending queue", self.sender._session_unhealthy_reason() or ""
)
class ProcessedHandMergeTest(unittest.TestCase):
@staticmethod
def raw_frame(right_b: int) -> dict[str, object]:
return {
"button": {
"left": [0, 0, 0],
"right": [0, right_b, 0],
},
"trigger": {"left": 0.12, "right": 0.73},
"hand": {"position": {"left": 0.12, "right": 0.73}},
}
@staticmethod
def processed_command() -> dict[str, object]:
return {
"timestamp": 123.5,
"hand": {
"position": {
"left": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
"right": [0.6, 0.5, 0.4, 0.3, 0.2, 0.1],
}
},
}
def test_processed_command_cannot_run_ahead_of_raw_timestamp(self) -> None:
command = self.processed_command()
self.assertFalse(
sender_module.XteleSender._command_aligned_with_raw(
command, {"timestamp": 123.4}
)
)
self.assertTrue(
sender_module.XteleSender._command_aligned_with_raw(
command, {"timestamp": 123.5}
)
)
self.assertTrue(
sender_module.XteleSender._command_aligned_with_raw(
command, {"timestamp": 124.0}
)
)
self.assertFalse(
sender_module.XteleSender._command_aligned_with_raw(
command, {"timestamp": "124.0"}
)
)
def build(
self,
data: dict[str, object],
suppress_processed_right: bool | None = None,
) -> tuple[dict[str, object], bool]:
encoded, merged = sender_module.XteleSender._build_payload(
data,
self.processed_command(),
"session-id",
7,
"active",
suppress_processed_right=suppress_processed_right,
)
return json.loads(encoded), merged
def test_right_b_preserves_raw_right_hand_and_trigger(self) -> None:
raw = self.raw_frame(right_b=1)
original = copy.deepcopy(raw)
payload, merged = self.build(raw)
self.assertTrue(merged)
self.assertEqual(
payload["hand"]["position"]["left"],
self.processed_command()["hand"]["position"]["left"],
)
self.assertEqual(payload["hand"]["position"]["right"], 0.73)
self.assertEqual(payload["trigger"], original["trigger"])
self.assertEqual(
payload["tg3_transport"]["processed_hand_sides"], ["left"]
)
self.assertTrue(
payload["tg3_transport"][
"processed_right_hand_suppressed_by_b"
]
)
self.assertEqual(raw, original, "payload building must not mutate raw 5003")
def test_right_b_release_restores_bilateral_processed_merge(self) -> None:
raw = self.raw_frame(right_b=0)
payload, merged = self.build(raw)
self.assertTrue(merged)
self.assertEqual(
payload["hand"]["position"],
self.processed_command()["hand"]["position"],
)
self.assertEqual(payload["trigger"]["right"], 0.73)
self.assertEqual(
payload["tg3_transport"]["processed_hand_sides"],
["left", "right"],
)
self.assertNotIn(
"processed_right_hand_suppressed_by_b",
payload["tg3_transport"],
)
def test_runtime_gate_suppresses_through_stable_b_release(self) -> None:
sender = sender_module.XteleSender(args())
pressed = self.raw_frame(right_b=1)
released = self.raw_frame(right_b=0)
baseline = self.processed_command()["hand"]["position"]["right"]
b_gesture = [0.2, 0.688, 0.0, 0.98, 0.98, 0.98]
self.assertFalse(sender._update_right_b_merge_gate(0.0, released, baseline))
self.assertFalse(
sender._update_right_b_merge_gate(0.26, released, baseline)
)
self.assertTrue(
sender._update_right_b_merge_gate(1.0, pressed, b_gesture)
)
self.assertTrue(
sender._update_right_b_merge_gate(1.1, released, b_gesture)
)
self.assertTrue(
sender._update_right_b_merge_gate(1.61, released, b_gesture)
)
held_payload, _ = self.build(
released, suppress_processed_right=True
)
self.assertEqual(held_payload["hand"]["position"]["right"], 0.73)
# Stable B release is not enough: processed 5001 must also remain at
# a safe baseline for at least cmd_max_age_s before it is trusted.
self.assertTrue(
sender._update_right_b_merge_gate(1.62, released, baseline)
)
self.assertTrue(
sender._update_right_b_merge_gate(1.86, released, baseline)
)
self.assertFalse(
sender._update_right_b_merge_gate(1.88, released, baseline)
)
restored_payload, _ = self.build(
released, suppress_processed_right=False
)
self.assertEqual(
restored_payload["hand"]["position"]["right"],
self.processed_command()["hand"]["position"]["right"],
)
def test_malformed_b_cannot_clear_runtime_suppression(self) -> None:
sender = sender_module.XteleSender(args())
self.assertTrue(
sender._update_right_b_merge_gate(
1.0, self.raw_frame(right_b=1), None
)
)
malformed = self.raw_frame(right_b=0)
malformed["button"] = {"right": [0]}
self.assertTrue(
sender._update_right_b_merge_gate(2.0, malformed, 0.73)
)
self.assertIsNone(sender.right_b_release_started_at)
def test_one_early_processed_change_cannot_replace_pre_b_baseline(self) -> None:
sender = sender_module.XteleSender(args())
released = self.raw_frame(right_b=0)
pressed = self.raw_frame(right_b=1)
baseline = self.processed_command()["hand"]["position"]["right"]
b_gesture = [0.2, 0.688, 0.0, 0.98, 0.98, 0.98]
sender._update_right_b_merge_gate(0.0, released, baseline)
sender._update_right_b_merge_gate(0.26, released, baseline)
self.assertEqual(sender.right_b_processed_baseline, baseline)
# Model 5001 being polled just before the corresponding raw B frame.
# It may become a candidate, but cannot immediately replace the last
# stable, B-false baseline.
sender._update_right_b_merge_gate(1.0, released, b_gesture)
self.assertEqual(sender.right_b_processed_baseline, baseline)
sender._update_right_b_merge_gate(1.01, pressed, b_gesture)
sender._update_right_b_merge_gate(1.02, released, b_gesture)
self.assertTrue(
sender._update_right_b_merge_gate(1.53, released, b_gesture)
)
self.assertTrue(
sender._update_right_b_merge_gate(2.0, released, b_gesture)
)
if __name__ == "__main__":
unittest.main()