From 522c7722f8771863471c606a36dba11702544f54 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 16:23:58 +0000 Subject: [PATCH 1/9] Add Nav map camera stream backed by pyddlvector nav feed --- README.md | 3 +- custom_components/vector/camera.py | 42 ++++++- custom_components/vector/coordinator.py | 108 ++++++++++++++++++ custom_components/vector/manifest.json | 2 +- custom_components/vector/strings.json | 3 + custom_components/vector/translations/en.json | 3 + tests/test_camera.py | 39 ++++++- 7 files changed, 196 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 62530eb..eccf7e4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ After setup, Home Assistant can expose data from Vector, including: - Eye color preset control (disabled by default) - Quick actions as buttons (sleep, go home, explore, listen for a beat, fetch cube) - Vision camera entity (disabled by default) +- Nav map camera entity (disabled by default) - Home Assistant actions/services: - `vector.say_text` - `vector.set_eye_color` @@ -77,7 +78,7 @@ To enable them: 1. Open your Vector device in Home Assistant. 2. Open the entity list. -3. Enable entities like `Vision`, `Volume`, `Stimulation`, and diagnostic sensors as needed. +3. Enable entities like `Vision`, `Nav map`, `Volume`, `Stimulation`, and diagnostic sensors as needed. ## Actions for Automations diff --git a/custom_components/vector/camera.py b/custom_components/vector/camera.py index dcd6f6f..ab38be4 100644 --- a/custom_components/vector/camera.py +++ b/custom_components/vector/camera.py @@ -19,7 +19,12 @@ async def async_setup_entry( ) -> None: """Set up Vector camera entities from config entry.""" coordinator: VectorCoordinator = entry.runtime_data["coordinator"] - async_add_entities([VectorVisionCamera(coordinator, entry)]) + async_add_entities( + [ + VectorVisionCamera(coordinator, entry), + VectorNavMapCamera(coordinator, entry), + ] + ) class VectorVisionCamera(VectorEntity, Camera): @@ -58,3 +63,38 @@ async def async_camera_image( return frame return self._assets.image_bytes(VectorAsset.IMG_UNKNOWN) + + +class VectorNavMapCamera(VectorEntity, Camera): + """Vector nav map camera entity using NavMapFeed stream.""" + + _attr_has_entity_name = True + _attr_translation_key = "nav_map" + _attr_entity_registry_enabled_default = False + _attr_frame_interval = 0.5 + + def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None: + """Initialize Vector nav map camera entity.""" + Camera.__init__(self) + VectorEntity.__init__(self, coordinator, entry) + self._attr_unique_id = f"{entry.entry_id}_nav_map" + self._assets = VectorAssetHandler() + + async def async_added_to_hass(self) -> None: + """Prepare bundled fallback assets.""" + await super().async_added_to_hass() + await self._assets.async_prepare(self.hass) + + async def async_camera_image( + self, + width: int | None = None, + height: int | None = None, + ) -> bytes | None: + """Return latest nav map PNG frame bytes.""" + del width, height + + frame = await self.coordinator.async_get_latest_nav_map_frame(wait_timeout=1.0) + if frame is not None: + return frame + + return self._assets.image_bytes(VectorAsset.IMG_UNKNOWN) diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index 9fc1f39..b332b74 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -35,6 +35,9 @@ _INITIAL_REFRESH_MAX_RETRY_DELAY_SECONDS = 60.0 _CAMERA_STREAM_READ_TIMEOUT_SECONDS = 30.0 _CAMERA_RECONNECT_DELAY_SECONDS = 2.0 +_NAV_MAP_WAIT_TIMEOUT_SECONDS = 1.0 +_NAV_MAP_RECONNECT_DELAY_SECONDS = 2.0 +_NAV_MAP_FEED_FREQUENCY_HZ = 2.0 _AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0 _AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0 _APP_INTENT_RPC_PATH = "/Anki.Vector.external_interface.ExternalInterface/AppIntent" @@ -94,19 +97,25 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.lift_height_mm: float | None = None self.camera_frame: bytes | None = None self.camera_frame_updated_monotonic: float | None = None + self.nav_map_frame: bytes | None = None + self.nav_map_frame_updated_monotonic: float | None = None self._client: Any | None = None self._robot_config: Any | None = None self._pyddlvector: Any | None = None self._messaging: Any | None = None + self._latest_robot_state: Any | None = None self._activity_tracker: Any | None = None self._telemetry_filter: Any | None = None self._event_listener_task: asyncio.Task[None] | None = None self._camera_stream_task: asyncio.Task[None] | None = None + self._nav_map_stream_task: asyncio.Task[None] | None = None self._wake_enable_stream_task: asyncio.Task[None] | None = None self._wake_camera_restart_task: asyncio.Task[None] | None = None self._camera_stream_lock = asyncio.Lock() + self._nav_map_stream_lock = asyncio.Lock() self._image_stream_enable_lock = asyncio.Lock() self._camera_frame_event = asyncio.Event() + self._nav_map_frame_event = asyncio.Event() self._settings_lock = asyncio.Lock() self._auth_backoff_delay_seconds = _AUTH_BACKOFF_BASE_DELAY_SECONDS self._auth_backoff_lock = asyncio.Lock() @@ -257,6 +266,15 @@ async def async_shutdown(self) -> None: finally: self._camera_stream_task = None + if self._nav_map_stream_task is not None: + self._nav_map_stream_task.cancel() + try: + await self._nav_map_stream_task + except asyncio.CancelledError: + pass + finally: + self._nav_map_stream_task = None + if self._wake_enable_stream_task is not None: self._wake_enable_stream_task.cancel() try: @@ -385,6 +403,7 @@ async def _async_event_listener_loop(self) -> None: has_changes = False if event_type == "robot_state": robot_state = event.robot_state + self._latest_robot_state = robot_state previous_activity = self.current_activity if self._activity_tracker is not None: next_activity = _normalize_activity_state( @@ -802,6 +821,95 @@ async def async_get_latest_camera_frame( return self.camera_frame + async def async_start_nav_map_stream(self) -> None: + """Ensure persistent nav map stream task is running.""" + async with self._nav_map_stream_lock: + if ( + self._nav_map_stream_task is not None + and not self._nav_map_stream_task.done() + ): + return + self._nav_map_stream_task = self.hass.async_create_background_task( + self._async_nav_map_stream_loop(), + name=f"vector_nav_map_stream_{self.entry.entry_id}", + ) + + async def async_get_latest_nav_map_frame( + self, + *, + wait_timeout: float = _NAV_MAP_WAIT_TIMEOUT_SECONDS, + ) -> bytes | None: + """Return latest nav map PNG frame, optionally waiting for first frame.""" + await self.async_start_nav_map_stream() + + if self.nav_map_frame is not None: + return self.nav_map_frame + + try: + await asyncio.wait_for( + self._nav_map_frame_event.wait(), timeout=wait_timeout + ) + except TimeoutError: + return None + + return self.nav_map_frame + + def _nav_map_robot_pose_provider(self) -> Any | None: + """Return current robot pose in nav-map coordinates when available.""" + if self._pyddlvector is None: + return None + if not hasattr(self._pyddlvector, "nav_map_robot_pose_from_state"): + return None + robot_state = self._latest_robot_state + if robot_state is None: + return None + return self._pyddlvector.nav_map_robot_pose_from_state(robot_state) + + async def _async_nav_map_stream_loop(self) -> None: + """Keep nav-map feed stream and cache latest rendered PNG frame.""" + while True: + try: + client, _ = await self._async_get_client() + pyddlvector, _ = await self._async_get_modules() + if not hasattr(pyddlvector, "iter_nav_map_frames"): + _LOGGER.debug( + "pyddlvector does not provide iter_nav_map_frames; nav map camera disabled" + ) + return + + async for frame in pyddlvector.iter_nav_map_frames( + client, + frequency=_NAV_MAP_FEED_FREQUENCY_HZ, + read_timeout=_CAMERA_STREAM_READ_TIMEOUT_SECONDS, + reconnect_delay=_NAV_MAP_RECONNECT_DELAY_SECONDS, + robot_pose_provider=self._nav_map_robot_pose_provider, + ): + frame_bytes = bytes(getattr(frame, "data", b"")) + if not frame_bytes: + continue + self.nav_map_frame = frame_bytes + self.nav_map_frame_updated_monotonic = time.monotonic() + self._nav_map_frame_event.set() + except asyncio.CancelledError: + raise + except Exception as err: + if _is_unauthenticated_error(err): + await self._async_handle_auth_failure("nav map stream", err) + continue + details = str(err).strip() + if details: + _LOGGER.debug("Vector nav map stream interrupted: %s", details) + else: + _LOGGER.debug( + "Vector nav map stream interrupted (%s)", + err.__class__.__name__, + exc_info=True, + ) + await asyncio.sleep(_NAV_MAP_RECONNECT_DELAY_SECONDS) + continue + + await asyncio.sleep(_NAV_MAP_RECONNECT_DELAY_SECONDS) + async def _async_camera_stream_loop(self) -> None: """Keep persistent camera feed stream and cache latest frame.""" while True: diff --git a/custom_components/vector/manifest.json b/custom_components/vector/manifest.json index 5592c1b..f6cb95b 100644 --- a/custom_components/vector/manifest.json +++ b/custom_components/vector/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/MTrab/vector/issues", "requirements": [ - "pyddlvector@git+https://github.com/MTrab/pyddlvector.git@main" + "pyddlvector@git+https://github.com/MTrab/pyddlvector.git@feat/navmap-frame-feed" ], "version": "0.1.0", "zeroconf": [ diff --git a/custom_components/vector/strings.json b/custom_components/vector/strings.json index 0b7647b..d0c44cb 100644 --- a/custom_components/vector/strings.json +++ b/custom_components/vector/strings.json @@ -3,6 +3,9 @@ "camera": { "vision": { "name": "Vision" + }, + "nav_map": { + "name": "Nav map" } }, "button": { diff --git a/custom_components/vector/translations/en.json b/custom_components/vector/translations/en.json index 5e6d531..580c9ee 100644 --- a/custom_components/vector/translations/en.json +++ b/custom_components/vector/translations/en.json @@ -3,6 +3,9 @@ "camera": { "vision": { "name": "Vision" + }, + "nav_map": { + "name": "Nav map" } }, "button": { diff --git a/tests/test_camera.py b/tests/test_camera.py index 4c4eed4..2341564 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -5,7 +5,7 @@ import asyncio from types import SimpleNamespace -from custom_components.vector.camera import VectorVisionCamera +from custom_components.vector.camera import VectorNavMapCamera, VectorVisionCamera from custom_components.vector.const import CONF_HOST, CONF_ROBOT_NAME @@ -15,7 +15,9 @@ class FakeCoordinator: def __init__(self, *, activity: str, frame: bytes | None) -> None: self.current_activity = activity self._frame = frame + self._nav_map_frame = frame self.start_calls = 0 + self.nav_map_start_calls = 0 def async_add_listener(self, update_callback): del update_callback @@ -30,6 +32,15 @@ async def async_get_latest_camera_frame( del wait_timeout return self._frame + async def async_start_nav_map_stream(self) -> None: + self.nav_map_start_calls += 1 + + async def async_get_latest_nav_map_frame( + self, *, wait_timeout: float = 1.0 + ) -> bytes | None: + del wait_timeout + return self._nav_map_frame + def _entry(data: dict[str, str], entry_id: str = "entry-1") -> SimpleNamespace: return SimpleNamespace(data=data, entry_id=entry_id) @@ -69,3 +80,29 @@ def test_camera_returns_live_frame_when_available() -> None: image = asyncio.run(entity.async_camera_image()) assert image == b"\xff\xd8\xff" + + +def test_nav_map_camera_entity_disabled_by_default() -> None: + coordinator = FakeCoordinator(activity="idle", frame=b"\x89PNG") + entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"}) + entity = VectorNavMapCamera(coordinator, entry) + assert entity.entity_registry_enabled_default is False + + +def test_nav_map_camera_returns_unknown_asset_when_no_frame() -> None: + coordinator = FakeCoordinator(activity="idle", frame=None) + entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"}) + entity = VectorNavMapCamera(coordinator, entry) + + image = asyncio.run(entity.async_camera_image()) + assert image is not None + assert image.startswith(b"\x89PNG") + + +def test_nav_map_camera_returns_live_frame_when_available() -> None: + coordinator = FakeCoordinator(activity="idle", frame=b"\x89PNG\r\n\x1a\nframe") + entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"}) + entity = VectorNavMapCamera(coordinator, entry) + + image = asyncio.run(entity.async_camera_image()) + assert image == b"\x89PNG\r\n\x1a\nframe" From 6a4d4c7d3ce05d88f739c032c2f4264a2b300b04 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 16:38:42 +0000 Subject: [PATCH 2/9] Remove nav map camera fallback assets --- custom_components/vector/camera.py | 12 +----------- tests/test_camera.py | 5 ++--- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/custom_components/vector/camera.py b/custom_components/vector/camera.py index ab38be4..7c42f19 100644 --- a/custom_components/vector/camera.py +++ b/custom_components/vector/camera.py @@ -78,12 +78,6 @@ def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None: Camera.__init__(self) VectorEntity.__init__(self, coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_nav_map" - self._assets = VectorAssetHandler() - - async def async_added_to_hass(self) -> None: - """Prepare bundled fallback assets.""" - await super().async_added_to_hass() - await self._assets.async_prepare(self.hass) async def async_camera_image( self, @@ -93,8 +87,4 @@ async def async_camera_image( """Return latest nav map PNG frame bytes.""" del width, height - frame = await self.coordinator.async_get_latest_nav_map_frame(wait_timeout=1.0) - if frame is not None: - return frame - - return self._assets.image_bytes(VectorAsset.IMG_UNKNOWN) + return await self.coordinator.async_get_latest_nav_map_frame(wait_timeout=1.0) diff --git a/tests/test_camera.py b/tests/test_camera.py index 2341564..fb16f4d 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -89,14 +89,13 @@ def test_nav_map_camera_entity_disabled_by_default() -> None: assert entity.entity_registry_enabled_default is False -def test_nav_map_camera_returns_unknown_asset_when_no_frame() -> None: +def test_nav_map_camera_returns_none_when_no_frame() -> None: coordinator = FakeCoordinator(activity="idle", frame=None) entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"}) entity = VectorNavMapCamera(coordinator, entry) image = asyncio.run(entity.async_camera_image()) - assert image is not None - assert image.startswith(b"\x89PNG") + assert image is None def test_nav_map_camera_returns_live_frame_when_available() -> None: From 7a65099b2e24d8fc0c27c8434e6d3017974859d7 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 16:45:05 +0000 Subject: [PATCH 3/9] Increase nav map camera update frequency --- custom_components/vector/camera.py | 2 +- custom_components/vector/coordinator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/vector/camera.py b/custom_components/vector/camera.py index 7c42f19..f866e53 100644 --- a/custom_components/vector/camera.py +++ b/custom_components/vector/camera.py @@ -71,7 +71,7 @@ class VectorNavMapCamera(VectorEntity, Camera): _attr_has_entity_name = True _attr_translation_key = "nav_map" _attr_entity_registry_enabled_default = False - _attr_frame_interval = 0.5 + _attr_frame_interval = 0.2 def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None: """Initialize Vector nav map camera entity.""" diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index b332b74..e9adda1 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -37,7 +37,7 @@ _CAMERA_RECONNECT_DELAY_SECONDS = 2.0 _NAV_MAP_WAIT_TIMEOUT_SECONDS = 1.0 _NAV_MAP_RECONNECT_DELAY_SECONDS = 2.0 -_NAV_MAP_FEED_FREQUENCY_HZ = 2.0 +_NAV_MAP_FEED_FREQUENCY_HZ = 5.0 _AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0 _AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0 _APP_INTENT_RPC_PATH = "/Anki.Vector.external_interface.ExternalInterface/AppIntent" From e61f6e5fb165a9c9f33aec93c75b7bbe3da436d2 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 16:47:05 +0000 Subject: [PATCH 4/9] Fix nav map camera content type and first-frame wait --- custom_components/vector/camera.py | 3 ++- custom_components/vector/coordinator.py | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/custom_components/vector/camera.py b/custom_components/vector/camera.py index f866e53..759573e 100644 --- a/custom_components/vector/camera.py +++ b/custom_components/vector/camera.py @@ -78,6 +78,7 @@ def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None: Camera.__init__(self) VectorEntity.__init__(self, coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_nav_map" + self.content_type = "image/png" async def async_camera_image( self, @@ -87,4 +88,4 @@ async def async_camera_image( """Return latest nav map PNG frame bytes.""" del width, height - return await self.coordinator.async_get_latest_nav_map_frame(wait_timeout=1.0) + return await self.coordinator.async_get_latest_nav_map_frame(wait_timeout=None) diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index e9adda1..5dc37a0 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -837,7 +837,7 @@ async def async_start_nav_map_stream(self) -> None: async def async_get_latest_nav_map_frame( self, *, - wait_timeout: float = _NAV_MAP_WAIT_TIMEOUT_SECONDS, + wait_timeout: float | None = _NAV_MAP_WAIT_TIMEOUT_SECONDS, ) -> bytes | None: """Return latest nav map PNG frame, optionally waiting for first frame.""" await self.async_start_nav_map_stream() @@ -846,9 +846,12 @@ async def async_get_latest_nav_map_frame( return self.nav_map_frame try: - await asyncio.wait_for( - self._nav_map_frame_event.wait(), timeout=wait_timeout - ) + if wait_timeout is None: + await self._nav_map_frame_event.wait() + else: + await asyncio.wait_for( + self._nav_map_frame_event.wait(), timeout=wait_timeout + ) except TimeoutError: return None From a44dea88d4c95af657f9d4571d956781f8e5ff04 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 16:49:15 +0000 Subject: [PATCH 5/9] Allow partial nav map frames in stream feed --- custom_components/vector/coordinator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index 5dc37a0..d621964 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -38,6 +38,7 @@ _NAV_MAP_WAIT_TIMEOUT_SECONDS = 1.0 _NAV_MAP_RECONNECT_DELAY_SECONDS = 2.0 _NAV_MAP_FEED_FREQUENCY_HZ = 5.0 +_NAV_MAP_MIN_COVERAGE_RATIO = 0.0 _AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0 _AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0 _APP_INTENT_RPC_PATH = "/Anki.Vector.external_interface.ExternalInterface/AppIntent" @@ -885,6 +886,7 @@ async def _async_nav_map_stream_loop(self) -> None: frequency=_NAV_MAP_FEED_FREQUENCY_HZ, read_timeout=_CAMERA_STREAM_READ_TIMEOUT_SECONDS, reconnect_delay=_NAV_MAP_RECONNECT_DELAY_SECONDS, + min_coverage_ratio=_NAV_MAP_MIN_COVERAGE_RATIO, robot_pose_provider=self._nav_map_robot_pose_provider, ): frame_bytes = bytes(getattr(frame, "data", b"")) From fc71fca531b31bc04cfcfe1f7735740872f2ccbb Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 17:22:51 +0000 Subject: [PATCH 6/9] Refresh nav map camera token on new frames --- custom_components/vector/camera.py | 23 ++++++++++++++++++++++- custom_components/vector/coordinator.py | 19 +++++++++++++++++++ tests/test_camera.py | 5 +++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/custom_components/vector/camera.py b/custom_components/vector/camera.py index 759573e..49ea27c 100644 --- a/custom_components/vector/camera.py +++ b/custom_components/vector/camera.py @@ -4,7 +4,7 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .assets import VectorAsset, VectorAssetHandler @@ -79,6 +79,27 @@ def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None: VectorEntity.__init__(self, coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_nav_map" self.content_type = "image/png" + self._unsub_nav_map_listener = None + + async def async_added_to_hass(self) -> None: + """Register nav-map update listener for cache-busting token refresh.""" + await super().async_added_to_hass() + + @callback + def _handle_nav_map_frame_update() -> None: + self.async_update_token() + self.async_write_ha_state() + + self._unsub_nav_map_listener = self.coordinator.async_add_nav_map_listener( + _handle_nav_map_frame_update + ) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe nav-map listener.""" + if self._unsub_nav_map_listener is not None: + self._unsub_nav_map_listener() + self._unsub_nav_map_listener = None + await super().async_will_remove_from_hass() async def async_camera_image( self, diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index d621964..f22a0f8 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -6,6 +6,7 @@ import logging import math import time +from collections.abc import Callable from typing import Any from homeassistant.config_entries import ConfigEntry @@ -117,6 +118,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._image_stream_enable_lock = asyncio.Lock() self._camera_frame_event = asyncio.Event() self._nav_map_frame_event = asyncio.Event() + self._nav_map_listeners: set[Callable[[], None]] = set() self._settings_lock = asyncio.Lock() self._auth_backoff_delay_seconds = _AUTH_BACKOFF_BASE_DELAY_SECONDS self._auth_backoff_lock = asyncio.Lock() @@ -858,6 +860,20 @@ async def async_get_latest_nav_map_frame( return self.nav_map_frame + def async_add_nav_map_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]: + """Register callback for nav-map frame updates.""" + self._nav_map_listeners.add(update_callback) + + def _remove_listener() -> None: + self._nav_map_listeners.discard(update_callback) + + return _remove_listener + + def _async_notify_nav_map_listeners(self) -> None: + """Notify registered nav-map listeners about a new frame.""" + for update_callback in tuple(self._nav_map_listeners): + update_callback() + def _nav_map_robot_pose_provider(self) -> Any | None: """Return current robot pose in nav-map coordinates when available.""" if self._pyddlvector is None: @@ -892,9 +908,12 @@ async def _async_nav_map_stream_loop(self) -> None: frame_bytes = bytes(getattr(frame, "data", b"")) if not frame_bytes: continue + if frame_bytes == self.nav_map_frame: + continue self.nav_map_frame = frame_bytes self.nav_map_frame_updated_monotonic = time.monotonic() self._nav_map_frame_event.set() + self._async_notify_nav_map_listeners() except asyncio.CancelledError: raise except Exception as err: diff --git a/tests/test_camera.py b/tests/test_camera.py index fb16f4d..126b61a 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -18,6 +18,7 @@ def __init__(self, *, activity: str, frame: bytes | None) -> None: self._nav_map_frame = frame self.start_calls = 0 self.nav_map_start_calls = 0 + self._nav_map_listener = None def async_add_listener(self, update_callback): del update_callback @@ -41,6 +42,10 @@ async def async_get_latest_nav_map_frame( del wait_timeout return self._nav_map_frame + def async_add_nav_map_listener(self, update_callback): + self._nav_map_listener = update_callback + return lambda: None + def _entry(data: dict[str, str], entry_id: str = "entry-1") -> SimpleNamespace: return SimpleNamespace(data=data, entry_id=entry_id) From 0139b10df3fb5bc75f8b617bc7e07761eb477f1c Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 17:26:05 +0000 Subject: [PATCH 7/9] Tune nav map stream for higher update throughput --- custom_components/vector/camera.py | 2 +- custom_components/vector/coordinator.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/vector/camera.py b/custom_components/vector/camera.py index 49ea27c..87c32fd 100644 --- a/custom_components/vector/camera.py +++ b/custom_components/vector/camera.py @@ -71,7 +71,7 @@ class VectorNavMapCamera(VectorEntity, Camera): _attr_has_entity_name = True _attr_translation_key = "nav_map" _attr_entity_registry_enabled_default = False - _attr_frame_interval = 0.2 + _attr_frame_interval = 0.1 def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None: """Initialize Vector nav map camera entity.""" diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index f22a0f8..a6bfdeb 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -37,8 +37,9 @@ _CAMERA_STREAM_READ_TIMEOUT_SECONDS = 30.0 _CAMERA_RECONNECT_DELAY_SECONDS = 2.0 _NAV_MAP_WAIT_TIMEOUT_SECONDS = 1.0 -_NAV_MAP_RECONNECT_DELAY_SECONDS = 2.0 -_NAV_MAP_FEED_FREQUENCY_HZ = 5.0 +_NAV_MAP_RECONNECT_DELAY_SECONDS = 0.25 +_NAV_MAP_FEED_FREQUENCY_HZ = 10.0 +_NAV_MAP_MAX_SIDE_PIXELS = 128 _NAV_MAP_MIN_COVERAGE_RATIO = 0.0 _AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0 _AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0 @@ -900,6 +901,7 @@ async def _async_nav_map_stream_loop(self) -> None: async for frame in pyddlvector.iter_nav_map_frames( client, frequency=_NAV_MAP_FEED_FREQUENCY_HZ, + max_side=_NAV_MAP_MAX_SIDE_PIXELS, read_timeout=_CAMERA_STREAM_READ_TIMEOUT_SECONDS, reconnect_delay=_NAV_MAP_RECONNECT_DELAY_SECONDS, min_coverage_ratio=_NAV_MAP_MIN_COVERAGE_RATIO, From 9d14a9563f300830ea82333dbd95a4d113c4efc1 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 17:28:11 +0000 Subject: [PATCH 8/9] Increase nav map render size to reduce blur --- custom_components/vector/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/vector/coordinator.py b/custom_components/vector/coordinator.py index a6bfdeb..6484674 100644 --- a/custom_components/vector/coordinator.py +++ b/custom_components/vector/coordinator.py @@ -39,7 +39,7 @@ _NAV_MAP_WAIT_TIMEOUT_SECONDS = 1.0 _NAV_MAP_RECONNECT_DELAY_SECONDS = 0.25 _NAV_MAP_FEED_FREQUENCY_HZ = 10.0 -_NAV_MAP_MAX_SIDE_PIXELS = 128 +_NAV_MAP_MAX_SIDE_PIXELS = 256 _NAV_MAP_MIN_COVERAGE_RATIO = 0.0 _AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0 _AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0 From b13775c70f7498894ef1a52a0d97b24587ca99fd Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Sat, 28 Feb 2026 18:12:10 +0000 Subject: [PATCH 9/9] Switch pyddlvector requirement back to main --- custom_components/vector/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/vector/manifest.json b/custom_components/vector/manifest.json index f6cb95b..5592c1b 100644 --- a/custom_components/vector/manifest.json +++ b/custom_components/vector/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/MTrab/vector/issues", "requirements": [ - "pyddlvector@git+https://github.com/MTrab/pyddlvector.git@feat/navmap-frame-feed" + "pyddlvector@git+https://github.com/MTrab/pyddlvector.git@main" ], "version": "0.1.0", "zeroconf": [