Skip to content

Commit 0f23794

Browse files
authored
Merge pull request #58 from MTrab/feature/navmap-camera-feed
Add Nav map camera stream entity
2 parents d4a9a9f + 9fdfc9a commit 0f23794

7 files changed

Lines changed: 196 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ After setup, Home Assistant can expose data from Vector, including:
1717
- Eye color preset control (disabled by default)
1818
- Quick actions as buttons (sleep, go home, explore, listen for a beat, fetch cube)
1919
- Vision camera entity (disabled by default)
20+
- Nav map camera entity (disabled by default)
2021
- Home Assistant actions/services:
2122
- `vector.say_text`
2223
- `vector.set_eye_color`
@@ -77,7 +78,7 @@ To enable them:
7778

7879
1. Open your Vector device in Home Assistant.
7980
2. Open the entity list.
80-
3. Enable entities like `Vision`, `Volume`, `Stimulation`, and diagnostic sensors as needed.
81+
3. Enable entities like `Vision`, `Nav map`, `Volume`, `Stimulation`, and diagnostic sensors as needed.
8182

8283
## Actions for Automations
8384

custom_components/vector/camera.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ async def async_setup_entry(
1919
) -> None:
2020
"""Set up Vector camera entities from config entry."""
2121
coordinator: VectorCoordinator = entry.runtime_data["coordinator"]
22-
async_add_entities([VectorVisionCamera(coordinator, entry)])
22+
async_add_entities(
23+
[
24+
VectorVisionCamera(coordinator, entry),
25+
VectorNavMapCamera(coordinator, entry),
26+
]
27+
)
2328

2429

2530
class VectorVisionCamera(VectorEntity, Camera):
@@ -58,3 +63,38 @@ async def async_camera_image(
5863
return frame
5964

6065
return self._assets.image_bytes(VectorAsset.IMG_UNKNOWN)
66+
67+
68+
class VectorNavMapCamera(VectorEntity, Camera):
69+
"""Vector nav map camera entity using NavMapFeed stream."""
70+
71+
_attr_has_entity_name = True
72+
_attr_translation_key = "nav_map"
73+
_attr_entity_registry_enabled_default = False
74+
_attr_frame_interval = 0.5
75+
76+
def __init__(self, coordinator: VectorCoordinator, entry: ConfigEntry) -> None:
77+
"""Initialize Vector nav map camera entity."""
78+
Camera.__init__(self)
79+
VectorEntity.__init__(self, coordinator, entry)
80+
self._attr_unique_id = f"{entry.entry_id}_nav_map"
81+
self._assets = VectorAssetHandler()
82+
83+
async def async_added_to_hass(self) -> None:
84+
"""Prepare bundled fallback assets."""
85+
await super().async_added_to_hass()
86+
await self._assets.async_prepare(self.hass)
87+
88+
async def async_camera_image(
89+
self,
90+
width: int | None = None,
91+
height: int | None = None,
92+
) -> bytes | None:
93+
"""Return latest nav map PNG frame bytes."""
94+
del width, height
95+
96+
frame = await self.coordinator.async_get_latest_nav_map_frame(wait_timeout=1.0)
97+
if frame is not None:
98+
return frame
99+
100+
return self._assets.image_bytes(VectorAsset.IMG_UNKNOWN)

custom_components/vector/coordinator.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
_INITIAL_REFRESH_MAX_RETRY_DELAY_SECONDS = 60.0
3636
_CAMERA_STREAM_READ_TIMEOUT_SECONDS = 30.0
3737
_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
3841
_AUTH_BACKOFF_BASE_DELAY_SECONDS = 15.0
3942
_AUTH_BACKOFF_MAX_DELAY_SECONDS = 300.0
4043
_APP_INTENT_RPC_PATH = "/Anki.Vector.external_interface.ExternalInterface/AppIntent"
@@ -94,19 +97,25 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
9497
self.lift_height_mm: float | None = None
9598
self.camera_frame: bytes | None = None
9699
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
97102
self._client: Any | None = None
98103
self._robot_config: Any | None = None
99104
self._pyddlvector: Any | None = None
100105
self._messaging: Any | None = None
106+
self._latest_robot_state: Any | None = None
101107
self._activity_tracker: Any | None = None
102108
self._telemetry_filter: Any | None = None
103109
self._event_listener_task: asyncio.Task[None] | None = None
104110
self._camera_stream_task: asyncio.Task[None] | None = None
111+
self._nav_map_stream_task: asyncio.Task[None] | None = None
105112
self._wake_enable_stream_task: asyncio.Task[None] | None = None
106113
self._wake_camera_restart_task: asyncio.Task[None] | None = None
107114
self._camera_stream_lock = asyncio.Lock()
115+
self._nav_map_stream_lock = asyncio.Lock()
108116
self._image_stream_enable_lock = asyncio.Lock()
109117
self._camera_frame_event = asyncio.Event()
118+
self._nav_map_frame_event = asyncio.Event()
110119
self._settings_lock = asyncio.Lock()
111120
self._auth_backoff_delay_seconds = _AUTH_BACKOFF_BASE_DELAY_SECONDS
112121
self._auth_backoff_lock = asyncio.Lock()
@@ -257,6 +266,15 @@ async def async_shutdown(self) -> None:
257266
finally:
258267
self._camera_stream_task = None
259268

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+
260278
if self._wake_enable_stream_task is not None:
261279
self._wake_enable_stream_task.cancel()
262280
try:
@@ -385,6 +403,7 @@ async def _async_event_listener_loop(self) -> None:
385403
has_changes = False
386404
if event_type == "robot_state":
387405
robot_state = event.robot_state
406+
self._latest_robot_state = robot_state
388407
previous_activity = self.current_activity
389408
if self._activity_tracker is not None:
390409
next_activity = _normalize_activity_state(
@@ -802,6 +821,95 @@ async def async_get_latest_camera_frame(
802821

803822
return self.camera_frame
804823

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+
805913
async def _async_camera_stream_loop(self) -> None:
806914
"""Keep persistent camera feed stream and cache latest frame."""
807915
while True:

custom_components/vector/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"iot_class": "local_push",
1515
"issue_tracker": "https://github.com/MTrab/vector/issues",
1616
"requirements": [
17-
"pyddlvector@git+https://github.com/MTrab/pyddlvector.git@main"
17+
"pyddlvector@git+https://github.com/MTrab/pyddlvector.git@feat/navmap-frame-feed"
1818
],
1919
"version": "0.1.0",
2020
"zeroconf": [

custom_components/vector/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"camera": {
44
"vision": {
55
"name": "Vision"
6+
},
7+
"nav_map": {
8+
"name": "Nav map"
69
}
710
},
811
"button": {

custom_components/vector/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"camera": {
44
"vision": {
55
"name": "Vision"
6+
},
7+
"nav_map": {
8+
"name": "Nav map"
69
}
710
},
811
"button": {

tests/test_camera.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import asyncio
66
from types import SimpleNamespace
77

8-
from custom_components.vector.camera import VectorVisionCamera
8+
from custom_components.vector.camera import VectorNavMapCamera, VectorVisionCamera
99
from custom_components.vector.const import CONF_HOST, CONF_ROBOT_NAME
1010

1111

@@ -15,7 +15,9 @@ class FakeCoordinator:
1515
def __init__(self, *, activity: str, frame: bytes | None) -> None:
1616
self.current_activity = activity
1717
self._frame = frame
18+
self._nav_map_frame = frame
1819
self.start_calls = 0
20+
self.nav_map_start_calls = 0
1921

2022
def async_add_listener(self, update_callback):
2123
del update_callback
@@ -30,6 +32,15 @@ async def async_get_latest_camera_frame(
3032
del wait_timeout
3133
return self._frame
3234

35+
async def async_start_nav_map_stream(self) -> None:
36+
self.nav_map_start_calls += 1
37+
38+
async def async_get_latest_nav_map_frame(
39+
self, *, wait_timeout: float = 1.0
40+
) -> bytes | None:
41+
del wait_timeout
42+
return self._nav_map_frame
43+
3344

3445
def _entry(data: dict[str, str], entry_id: str = "entry-1") -> SimpleNamespace:
3546
return SimpleNamespace(data=data, entry_id=entry_id)
@@ -69,3 +80,29 @@ def test_camera_returns_live_frame_when_available() -> None:
6980

7081
image = asyncio.run(entity.async_camera_image())
7182
assert image == b"\xff\xd8\xff"
83+
84+
85+
def test_nav_map_camera_entity_disabled_by_default() -> None:
86+
coordinator = FakeCoordinator(activity="idle", frame=b"\x89PNG")
87+
entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"})
88+
entity = VectorNavMapCamera(coordinator, entry)
89+
assert entity.entity_registry_enabled_default is False
90+
91+
92+
def test_nav_map_camera_returns_unknown_asset_when_no_frame() -> None:
93+
coordinator = FakeCoordinator(activity="idle", frame=None)
94+
entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"})
95+
entity = VectorNavMapCamera(coordinator, entry)
96+
97+
image = asyncio.run(entity.async_camera_image())
98+
assert image is not None
99+
assert image.startswith(b"\x89PNG")
100+
101+
102+
def test_nav_map_camera_returns_live_frame_when_available() -> None:
103+
coordinator = FakeCoordinator(activity="idle", frame=b"\x89PNG\r\n\x1a\nframe")
104+
entry = _entry({CONF_ROBOT_NAME: "Vector-ABCD", CONF_HOST: "192.168.1.10"})
105+
entity = VectorNavMapCamera(coordinator, entry)
106+
107+
image = asyncio.run(entity.async_camera_image())
108+
assert image == b"\x89PNG\r\n\x1a\nframe"

0 commit comments

Comments
 (0)