From bbbef2517ec33280f13ca81233029491f7c6193c Mon Sep 17 00:00:00 2001 From: Wanbogang Date: Sun, 22 Feb 2026 09:37:10 +0700 Subject: [PATCH 1/3] fix(person-follow): reuse aiohttp ClientSession and throttle TRACKING reports --- src/inputs/plugins/person_following_status.py | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/src/inputs/plugins/person_following_status.py b/src/inputs/plugins/person_following_status.py index 1330ea449..7eede233f 100644 --- a/src/inputs/plugins/person_following_status.py +++ b/src/inputs/plugins/person_following_status.py @@ -1,5 +1,3 @@ -# src/inputs/plugins/person_following_status.py - import asyncio import logging import time @@ -87,6 +85,9 @@ def __init__(self, config: PersonFollowingStatusConfig): self._last_enroll_attempt: float = 0.0 self._has_ever_tracked: bool = False + self._session: Optional[aiohttp.ClientSession] = None + self._last_tracking_report: float = 0.0 + logging.info( f"PersonFollowingStatus initialized, polling {self.status_url} " f"every {self.poll_interval}s, re-enroll every {self.enroll_retry_interval}s when not tracking" @@ -107,40 +108,39 @@ async def _poll(self) -> Optional[str]: await asyncio.sleep(self.poll_interval) try: - async with aiohttp.ClientSession() as session: - # First, get current status - async with session.get( - self.status_url, - timeout=aiohttp.ClientTimeout(total=2), - ) as response: - if response.status != 200: - return None - - data = await response.json() - is_tracked = data.get("is_tracked", False) - status = data.get("status", "UNKNOWN") - target_track_id = data.get("target_track_id") - - # If tracking, remember we've successfully tracked - if is_tracked: - self._has_ever_tracked = True - - # Only retry enrollment if INACTIVE (no one enrolled yet) - # Do NOT re-enroll if SEARCHING (person enrolled but temporarily out of frame) - if status == "INACTIVE" and target_track_id is None: - current_time = time.time() - time_since_last_enroll = ( - current_time - self._last_enroll_attempt - ) + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() - if time_since_last_enroll >= self.enroll_retry_interval: - self._last_enroll_attempt = current_time - logging.info( - "PersonFollowingStatus: Status INACTIVE, attempting enrollment" - ) - await self._try_enroll(session) + async with self._session.get( + self.status_url, + timeout=aiohttp.ClientTimeout(total=2), + ) as response: + if response.status != 200: + return None + + data = await response.json() + is_tracked = data.get("is_tracked", False) + status = data.get("status", "UNKNOWN") + target_track_id = data.get("target_track_id") + + # If tracking, remember we've successfully tracked + if is_tracked: + self._has_ever_tracked = True + + # Only retry enrollment if INACTIVE (no one enrolled yet) + # Do NOT re-enroll if SEARCHING (person enrolled but temporarily out of frame) + if status == "INACTIVE" and target_track_id is None: + current_time = time.time() + time_since_last_enroll = current_time - self._last_enroll_attempt + + if time_since_last_enroll >= self.enroll_retry_interval: + self._last_enroll_attempt = current_time + logging.info( + "PersonFollowingStatus: Status INACTIVE, attempting enrollment" + ) + await self._try_enroll(self._session) - return self._format_status(data) + return self._format_status(data) except aiohttp.ClientError as e: logging.debug(f"PersonFollowingStatus: Poll failed: {e}") @@ -215,6 +215,7 @@ def _format_status(self, data: dict) -> Optional[str]: # Person was acquired - always report this self._lost_tracking_time = None self._lost_tracking_announced = False + self._last_tracking_report = current_time return f"TRACKING STARTED: Person detected and now following. Distance: {z:.1f}m ahead, {x:.1f}m to the side." if tracking_just_lost: @@ -242,7 +243,10 @@ def _format_status(self, data: dict) -> Optional[str]: return None # Currently tracking - provide occasional updates - # Only report significant distance changes or periodically + if (current_time - self._last_tracking_report) < 10.0: + return None + + self._last_tracking_report = current_time return f"TRACKING: Following person at {z:.1f}m ahead, {x:.1f}m to the side. Status: {status}" async def _raw_to_text(self, raw_input: Optional[str]) -> Optional[Message]: From 9d971ffc2faa7bc5f729bb1e8672b7c544b241cb Mon Sep 17 00:00:00 2001 From: Wanbogang Date: Sun, 22 Feb 2026 09:37:25 +0700 Subject: [PATCH 2/3] fix(person-follow): remove duplicate start_person_follow_hook from on_startup --- config/unitree_go2_modes.json5 | 9 --------- 1 file changed, 9 deletions(-) diff --git a/config/unitree_go2_modes.json5 b/config/unitree_go2_modes.json5 index 4ae484fa6..bdfe5bf07 100644 --- a/config/unitree_go2_modes.json5 +++ b/config/unitree_go2_modes.json5 @@ -687,15 +687,6 @@ VOICE RULE:\n\ }, ], lifecycle_hooks: [ - { - hook_type: "on_startup", - handler_type: "function", - handler_config: { - module_name: "person_follow_hook", - function: "start_person_follow_hook", - }, - priority: 1, - }, { hook_type: "on_startup", handler_type: "message", From a8b5940e8a7c2afd6b5e42a8f7499b72a234144b Mon Sep 17 00:00:00 2001 From: Wanbogang Date: Sun, 22 Feb 2026 09:37:45 +0700 Subject: [PATCH 3/3] test(person-follow): add tests for session reuse and tracking throttle --- .../plugins/test_person_following_status.py | 339 ++++++++++++++++-- 1 file changed, 309 insertions(+), 30 deletions(-) diff --git a/tests/inputs/plugins/test_person_following_status.py b/tests/inputs/plugins/test_person_following_status.py index 75d9c77b7..e2ca70614 100644 --- a/tests/inputs/plugins/test_person_following_status.py +++ b/tests/inputs/plugins/test_person_following_status.py @@ -1,5 +1,7 @@ -from unittest.mock import AsyncMock, patch +import time +from unittest.mock import AsyncMock, MagicMock, patch +import aiohttp import pytest from inputs.base import Message @@ -9,49 +11,326 @@ ) -def test_initialization(): - """Test basic initialization.""" +def make_sensor(): with patch("inputs.plugins.person_following_status.IOProvider"): config = PersonFollowingStatusConfig() - sensor = PersonFollowingStatus(config=config) + return PersonFollowingStatus(config=config) + + +def make_mock_session(response_data, response_status=200, enroll_status=200): + """Helper untuk membuat mock session dengan async context manager yang benar.""" + mock_response = MagicMock() + mock_response.status = response_status + mock_response.json = AsyncMock(return_value=response_data) - assert hasattr(sensor, "messages") + mock_session = MagicMock() + mock_session.closed = False + mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.get.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_enroll_response = MagicMock() + mock_enroll_response.status = enroll_status + mock_session.post.return_value.__aenter__ = AsyncMock( + return_value=mock_enroll_response + ) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=False) + + return mock_session + + +def test_initialization(): + """Test basic initialization.""" + sensor = make_sensor() + assert hasattr(sensor, "messages") @pytest.mark.asyncio async def test_poll(): """Test _poll method.""" - with patch("inputs.plugins.person_following_status.IOProvider"): - config = PersonFollowingStatusConfig() - sensor = PersonFollowingStatus(config=config) - - with patch( - "inputs.plugins.person_following_status.asyncio.sleep", new=AsyncMock() - ): - result = await sensor._poll() - assert result is None + sensor = make_sensor() + with patch("inputs.plugins.person_following_status.asyncio.sleep", new=AsyncMock()): + result = await sensor._poll() + assert result is None def test_formatted_latest_buffer(): """Test formatted_latest_buffer.""" - with patch("inputs.plugins.person_following_status.IOProvider"): - config = PersonFollowingStatusConfig() - sensor = PersonFollowingStatus(config=config) + sensor = make_sensor() - result = sensor.formatted_latest_buffer() - assert result is None + assert sensor.formatted_latest_buffer() is None - test_message = Message( + sensor.messages.append( + Message( timestamp=123.456, message="TRACKING STARTED: Person detected and now following. Distance: 2.5m ahead, 0.3m to the side.", ) - sensor.messages.append(test_message) - - result = sensor.formatted_latest_buffer() - assert isinstance(result, str) - assert "INPUT:" in result - assert "Person Following Status" in result - assert "TRACKING STARTED" in result - assert "// START" in result - assert "// END" in result - assert len(sensor.messages) == 0 + ) + result = sensor.formatted_latest_buffer() + assert isinstance(result, str) + assert "INPUT:" in result + assert "Person Following Status" in result + assert "TRACKING STARTED" in result + assert "// START" in result + assert "// END" in result + assert len(sensor.messages) == 0 + + +def test_session_is_none_on_init(): + """Test session belum dibuat saat inisialisasi.""" + sensor = make_sensor() + assert sensor._session is None + + +@pytest.mark.asyncio +async def test_session_reuse(): + """Test session yang sama dipakai ulang, tidak dibuat baru setiap poll.""" + sensor = make_sensor() + + sensor._session = aiohttp.ClientSession() + existing_session = sensor._session + + if sensor._session is None or sensor._session.closed: + sensor._session = aiohttp.ClientSession() + + assert sensor._session is existing_session + + await existing_session.close() + + +@pytest.mark.asyncio +async def test_session_recreated_if_closed(): + """Test session baru dibuat jika session sebelumnya sudah closed.""" + sensor = make_sensor() + + sensor._session = aiohttp.ClientSession() + await sensor._session.close() + assert sensor._session.closed + + if sensor._session is None or sensor._session.closed: + sensor._session = aiohttp.ClientSession() + + assert not sensor._session.closed + await sensor._session.close() + + +def test_tracking_throttle(): + """Test TRACKING message hanya dikirim setiap 10 detik, bukan setiap poll.""" + sensor = make_sensor() + sensor._previous_is_tracked = True + + data = { + "is_tracked": True, + "x": 0.1, + "z": 2.0, + "status": "TRACKING_ACTIVE", + "target_track_id": 1, + } + + sensor._last_tracking_report = time.time() + assert sensor._format_status(data) is None + + sensor._last_tracking_report = time.time() - 11.0 + result = sensor._format_status(data) + assert result is not None + assert "TRACKING" in result + + +def test_tracking_started_bypasses_throttle(): + """Test event TRACKING STARTED selalu dikirim meski throttle belum melewati 10 detik.""" + sensor = make_sensor() + sensor._previous_is_tracked = False + sensor._last_tracking_report = time.time() + + data = { + "is_tracked": True, + "x": 0.1, + "z": 2.0, + "status": "TRACKING_ACTIVE", + "target_track_id": 1, + } + + result = sensor._format_status(data) + assert result is not None + assert "TRACKING STARTED" in result + + +@pytest.mark.asyncio +async def test_poll_with_successful_response(): + """Test _poll saat HTTP response berhasil dan data tracking tersedia.""" + sensor = make_sensor() + + mock_session = make_mock_session( + { + "is_tracked": True, + "status": "TRACKING_ACTIVE", + "target_track_id": 1, + "x": 0.1, + "z": 2.0, + } + ) + + with patch("inputs.plugins.person_following_status.asyncio.sleep", new=AsyncMock()): + sensor._session = mock_session + await sensor._poll() + assert sensor._has_ever_tracked is True + + +@pytest.mark.asyncio +async def test_poll_inactive_triggers_enroll(): + """Test _poll memanggil enroll saat status INACTIVE.""" + sensor = make_sensor() + sensor._last_enroll_attempt = 0.0 + + mock_session = make_mock_session( + { + "is_tracked": False, + "status": "INACTIVE", + "target_track_id": None, + "x": 0.0, + "z": 0.0, + } + ) + + with patch("inputs.plugins.person_following_status.asyncio.sleep", new=AsyncMock()): + sensor._session = mock_session + await sensor._poll() + mock_session.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_poll_exception_handling(): + """Test _poll menangani exception dengan benar.""" + sensor = make_sensor() + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get.side_effect = Exception("unexpected error") + + with patch("inputs.plugins.person_following_status.asyncio.sleep", new=AsyncMock()): + sensor._session = mock_session + result = await sensor._poll() + assert result is None + + +@pytest.mark.asyncio +async def test_try_enroll_success(): + """Test _try_enroll saat server mengembalikan status 200.""" + sensor = make_sensor() + + mock_response = MagicMock() + mock_response.status = 200 + + mock_session = MagicMock() + mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=False) + + await sensor._try_enroll(mock_session) + mock_session.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_try_enroll_failure_status(): + """Test _try_enroll saat server mengembalikan status non-200.""" + sensor = make_sensor() + + mock_response = MagicMock() + mock_response.status = 500 + + mock_session = MagicMock() + mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=False) + + await sensor._try_enroll(mock_session) + mock_session.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_try_enroll_exception(): + """Test _try_enroll menangani exception dengan benar.""" + sensor = make_sensor() + + mock_session = MagicMock() + mock_session.post.side_effect = Exception("connection error") + + await sensor._try_enroll(mock_session) + + +def test_format_status_tracking_lost_searching(): + """Test _format_status mengembalikan SEARCHING saat tracking hilang dan status SEARCHING.""" + sensor = make_sensor() + sensor._previous_is_tracked = True + + data = { + "is_tracked": False, + "x": 0.0, + "z": 0.0, + "status": "SEARCHING", + "target_track_id": 1, + } + + sensor._format_status(data) + + sensor._lost_tracking_time = time.time() - 3.0 + result = sensor._format_status(data) + assert result is not None + assert "SEARCHING" in result + + +def test_format_status_tracking_lost_waiting(): + """Test _format_status mengembalikan WAITING saat status INACTIVE.""" + sensor = make_sensor() + sensor._previous_is_tracked = True + + data = { + "is_tracked": False, + "x": 0.0, + "z": 0.0, + "status": "INACTIVE", + "target_track_id": None, + } + + sensor._format_status(data) + sensor._lost_tracking_time = time.time() - 3.0 + result = sensor._format_status(data) + assert result is not None + assert "WAITING" in result + + +@pytest.mark.asyncio +async def test_raw_to_text_with_input(): + """Test raw_to_text menambahkan message ke buffer.""" + sensor = make_sensor() + + await sensor.raw_to_text("TRACKING STARTED: test message") + assert len(sensor.messages) == 1 + assert "TRACKING STARTED" in sensor.messages[0].message + + +@pytest.mark.asyncio +async def test_raw_to_text_with_none(): + """Test raw_to_text tidak menambahkan apapun saat input None.""" + sensor = make_sensor() + + await sensor.raw_to_text(None) + assert len(sensor.messages) == 0 + + +@pytest.mark.asyncio +async def test_poll_non_200_response(): + """Test _poll mengembalikan None saat response status bukan 200.""" + sensor = make_sensor() + + mock_session = make_mock_session({}, response_status=503) + + with patch("inputs.plugins.person_following_status.asyncio.sleep", new=AsyncMock()): + sensor._session = mock_session + result = await sensor._poll() + assert result is None + + +@pytest.mark.asyncio +async def test_raw_to_text_returns_none_for_none(): + """Test _raw_to_text mengembalikan None saat input None.""" + sensor = make_sensor() + result = await sensor._raw_to_text(None) + assert result is None