|
35 | 35 | _INITIAL_REFRESH_MAX_RETRY_DELAY_SECONDS = 60.0 |
36 | 36 | _CAMERA_STREAM_READ_TIMEOUT_SECONDS = 30.0 |
37 | 37 | _CAMERA_RECONNECT_DELAY_SECONDS = 2.0 |
| 38 | +_NAV_MAP_WAIT_TIMEOUT_SECONDS = 1.0 |
| 39 | +_NAV_MAP_RECONNECT_DELAY_SECONDS = 2.0 |
| 40 | +_NAV_MAP_FEED_FREQUENCY_HZ = 2.0 |
38 | 41 | _AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0 |
39 | 42 | _AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0 |
40 | 43 | _APP_INTENT_RPC_PATH = "/Anki.Vector.external_interface.ExternalInterface/AppIntent" |
@@ -94,19 +97,25 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: |
94 | 97 | self.lift_height_mm: float | None = None |
95 | 98 | self.camera_frame: bytes | None = None |
96 | 99 | self.camera_frame_updated_monotonic: float | None = None |
| 100 | + self.nav_map_frame: bytes | None = None |
| 101 | + self.nav_map_frame_updated_monotonic: float | None = None |
97 | 102 | self._client: Any | None = None |
98 | 103 | self._robot_config: Any | None = None |
99 | 104 | self._pyddlvector: Any | None = None |
100 | 105 | self._messaging: Any | None = None |
| 106 | + self._latest_robot_state: Any | None = None |
101 | 107 | self._activity_tracker: Any | None = None |
102 | 108 | self._telemetry_filter: Any | None = None |
103 | 109 | self._event_listener_task: asyncio.Task[None] | None = None |
104 | 110 | self._camera_stream_task: asyncio.Task[None] | None = None |
| 111 | + self._nav_map_stream_task: asyncio.Task[None] | None = None |
105 | 112 | self._wake_enable_stream_task: asyncio.Task[None] | None = None |
106 | 113 | self._wake_camera_restart_task: asyncio.Task[None] | None = None |
107 | 114 | self._camera_stream_lock = asyncio.Lock() |
| 115 | + self._nav_map_stream_lock = asyncio.Lock() |
108 | 116 | self._image_stream_enable_lock = asyncio.Lock() |
109 | 117 | self._camera_frame_event = asyncio.Event() |
| 118 | + self._nav_map_frame_event = asyncio.Event() |
110 | 119 | self._settings_lock = asyncio.Lock() |
111 | 120 | self._auth_backoff_delay_seconds = _AUTH_BACKOFF_BASE_DELAY_SECONDS |
112 | 121 | self._auth_backoff_lock = asyncio.Lock() |
@@ -257,6 +266,15 @@ async def async_shutdown(self) -> None: |
257 | 266 | finally: |
258 | 267 | self._camera_stream_task = None |
259 | 268 |
|
| 269 | + if self._nav_map_stream_task is not None: |
| 270 | + self._nav_map_stream_task.cancel() |
| 271 | + try: |
| 272 | + await self._nav_map_stream_task |
| 273 | + except asyncio.CancelledError: |
| 274 | + pass |
| 275 | + finally: |
| 276 | + self._nav_map_stream_task = None |
| 277 | + |
260 | 278 | if self._wake_enable_stream_task is not None: |
261 | 279 | self._wake_enable_stream_task.cancel() |
262 | 280 | try: |
@@ -385,6 +403,7 @@ async def _async_event_listener_loop(self) -> None: |
385 | 403 | has_changes = False |
386 | 404 | if event_type == "robot_state": |
387 | 405 | robot_state = event.robot_state |
| 406 | + self._latest_robot_state = robot_state |
388 | 407 | previous_activity = self.current_activity |
389 | 408 | if self._activity_tracker is not None: |
390 | 409 | next_activity = _normalize_activity_state( |
@@ -802,6 +821,95 @@ async def async_get_latest_camera_frame( |
802 | 821 |
|
803 | 822 | return self.camera_frame |
804 | 823 |
|
| 824 | + async def async_start_nav_map_stream(self) -> None: |
| 825 | + """Ensure persistent nav map stream task is running.""" |
| 826 | + async with self._nav_map_stream_lock: |
| 827 | + if ( |
| 828 | + self._nav_map_stream_task is not None |
| 829 | + and not self._nav_map_stream_task.done() |
| 830 | + ): |
| 831 | + return |
| 832 | + self._nav_map_stream_task = self.hass.async_create_background_task( |
| 833 | + self._async_nav_map_stream_loop(), |
| 834 | + name=f"vector_nav_map_stream_{self.entry.entry_id}", |
| 835 | + ) |
| 836 | + |
| 837 | + async def async_get_latest_nav_map_frame( |
| 838 | + self, |
| 839 | + *, |
| 840 | + wait_timeout: float = _NAV_MAP_WAIT_TIMEOUT_SECONDS, |
| 841 | + ) -> bytes | None: |
| 842 | + """Return latest nav map PNG frame, optionally waiting for first frame.""" |
| 843 | + await self.async_start_nav_map_stream() |
| 844 | + |
| 845 | + if self.nav_map_frame is not None: |
| 846 | + return self.nav_map_frame |
| 847 | + |
| 848 | + try: |
| 849 | + await asyncio.wait_for( |
| 850 | + self._nav_map_frame_event.wait(), timeout=wait_timeout |
| 851 | + ) |
| 852 | + except TimeoutError: |
| 853 | + return None |
| 854 | + |
| 855 | + return self.nav_map_frame |
| 856 | + |
| 857 | + def _nav_map_robot_pose_provider(self) -> Any | None: |
| 858 | + """Return current robot pose in nav-map coordinates when available.""" |
| 859 | + if self._pyddlvector is None: |
| 860 | + return None |
| 861 | + if not hasattr(self._pyddlvector, "nav_map_robot_pose_from_state"): |
| 862 | + return None |
| 863 | + robot_state = self._latest_robot_state |
| 864 | + if robot_state is None: |
| 865 | + return None |
| 866 | + return self._pyddlvector.nav_map_robot_pose_from_state(robot_state) |
| 867 | + |
| 868 | + async def _async_nav_map_stream_loop(self) -> None: |
| 869 | + """Keep nav-map feed stream and cache latest rendered PNG frame.""" |
| 870 | + while True: |
| 871 | + try: |
| 872 | + client, _ = await self._async_get_client() |
| 873 | + pyddlvector, _ = await self._async_get_modules() |
| 874 | + if not hasattr(pyddlvector, "iter_nav_map_frames"): |
| 875 | + _LOGGER.debug( |
| 876 | + "pyddlvector does not provide iter_nav_map_frames; nav map camera disabled" |
| 877 | + ) |
| 878 | + return |
| 879 | + |
| 880 | + async for frame in pyddlvector.iter_nav_map_frames( |
| 881 | + client, |
| 882 | + frequency=_NAV_MAP_FEED_FREQUENCY_HZ, |
| 883 | + read_timeout=_CAMERA_STREAM_READ_TIMEOUT_SECONDS, |
| 884 | + reconnect_delay=_NAV_MAP_RECONNECT_DELAY_SECONDS, |
| 885 | + robot_pose_provider=self._nav_map_robot_pose_provider, |
| 886 | + ): |
| 887 | + frame_bytes = bytes(getattr(frame, "data", b"")) |
| 888 | + if not frame_bytes: |
| 889 | + continue |
| 890 | + self.nav_map_frame = frame_bytes |
| 891 | + self.nav_map_frame_updated_monotonic = time.monotonic() |
| 892 | + self._nav_map_frame_event.set() |
| 893 | + except asyncio.CancelledError: |
| 894 | + raise |
| 895 | + except Exception as err: |
| 896 | + if _is_unauthenticated_error(err): |
| 897 | + await self._async_handle_auth_failure("nav map stream", err) |
| 898 | + continue |
| 899 | + details = str(err).strip() |
| 900 | + if details: |
| 901 | + _LOGGER.debug("Vector nav map stream interrupted: %s", details) |
| 902 | + else: |
| 903 | + _LOGGER.debug( |
| 904 | + "Vector nav map stream interrupted (%s)", |
| 905 | + err.__class__.__name__, |
| 906 | + exc_info=True, |
| 907 | + ) |
| 908 | + await asyncio.sleep(_NAV_MAP_RECONNECT_DELAY_SECONDS) |
| 909 | + continue |
| 910 | + |
| 911 | + await asyncio.sleep(_NAV_MAP_RECONNECT_DELAY_SECONDS) |
| 912 | + |
805 | 913 | async def _async_camera_stream_loop(self) -> None: |
806 | 914 | """Keep persistent camera feed stream and cache latest frame.""" |
807 | 915 | while True: |
|
0 commit comments