Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
52f85de
feat(spot): minimal Spot velocity control + keyboard teleop blueprint
jeff-hykin Jul 15, 2026
9cdad08
cmd_vel works
jeff-hykin Jul 16, 2026
d6067bc
feat(spot): flatten cmd_vel into SpotHighLevel; add cameras/record bl…
jeff-hykin Jul 17, 2026
bf92efe
add lossless recording option
jeff-hykin Jul 17, 2026
624997a
organize, untested
jeff-hykin Jul 17, 2026
21c6c88
fixup, hardware tested, odom timestamps fix
jeff-hykin Jul 18, 2026
1b49dfa
redo how rotations work, tested on hardware
jeff-hykin Jul 18, 2026
77d29a4
fix too large angular vel
jeff-hykin Jul 18, 2026
4e96faf
Merge remote-tracking branch 'origin/main' into jeff/feat/spot
jeff-hykin Jul 20, 2026
09efc07
docs(spot): move Spot README into Mintlify docs tree
jeff-hykin Jul 20, 2026
e7dd7a2
comment cleanup
jeff-hykin Jul 20, 2026
9f03c1c
refactor(spot): reorder utils helpers, drop stale docstring line
jeff-hykin Jul 20, 2026
a6f6c60
misc
jeff-hykin Jul 20, 2026
e54e6e4
chore(spot): drop stray spot-replay registration from no-replay PR
jeff-hykin Jul 20, 2026
7311e74
Merge branch 'main' into jeff/feat/spot
jeff-hykin Jul 20, 2026
6209f40
fix(spot): roll front camera optical tf to match upright images
jeff-hykin Jul 20, 2026
fcb3815
fix(spot): tear down on setup failure and fail loud on missing odom TF
jeff-hykin Jul 20, 2026
59827cb
-
jeff-hykin Jul 20, 2026
7760602
Merge branch 'main' into jeff/feat/spot
jeff-hykin Jul 23, 2026
0c3acdb
Merge remote-tracking branch 'origin/main' into _pr_merge
jeff-hykin Jul 27, 2026
cc2cfa4
Merge branch 'main' into jeff/feat/spot
jeff-hykin Jul 27, 2026
3fb81ea
Merge main into jeff/feat/spot
jeff-hykin Jul 27, 2026
c20a49a
Merge remote-tracking branch 'origin/main' into jeff/feat/spot
jeff-hykin Aug 10, 2026
5094d53
refactor(spot): move Spot support under dimos/experimental
jeff-hykin Aug 10, 2026
c57c36b
fix(spot): adapt to the retired TF service
jeff-hykin Aug 10, 2026
c6a7b1c
fix(spot): defer the rerun imports in spot/rerun.py
jeff-hykin Aug 10, 2026
901e71b
docs(spot): use the current CLI flag syntax, not the removed -o form
jeff-hykin Aug 10, 2026
04749b5
feat(spot): add spot-replay blueprint + per-camera frustum anchoring
jeff-hykin Aug 10, 2026
1e61021
feat(spot): draw the whole travelled path in spot-replay
jeff-hykin Aug 10, 2026
a0c8840
refactor(mapping): take the newer async-handler OdometryPath
jeff-hykin Aug 10, 2026
a3513c6
fixup jitter
jeff-hykin Aug 10, 2026
4fb8cf6
fix(blueprints): register OdometryPath in all_blueprints
jeff-hykin Aug 10, 2026
e644e5e
feat(spot): let spot-replay pull its recording from LFS by name
jeff-hykin Aug 10, 2026
82142b1
Merge remote-tracking branch 'origin/main' into jeff/feat/spot-replay2
jeff-hykin Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions dimos/experimental/robot/bosdyn/spot/blueprints/spot.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,23 @@
from dimos.core.coordination.blueprints import autoconnect
from dimos.core.global_config import global_config
from dimos.experimental.robot.bosdyn.spot.effectors.high_level import SpotHighLevel
from dimos.experimental.robot.bosdyn.spot.rerun import spot_camera_layout
from dimos.experimental.robot.bosdyn.spot.rerun import (
spot_camera_layout,
spot_camera_visual_overrides,
)
from dimos.navigation.movement_manager.movement_manager import MovementManager
from dimos.visualization.vis_module import vis_module

spot = autoconnect(
SpotHighLevel.blueprint(),
MovementManager.blueprint(),
vis_module(global_config.viewer, rerun_config={"blueprint": spot_camera_layout}),
vis_module(
global_config.viewer,
rerun_config={
"blueprint": spot_camera_layout,
"visual_override": spot_camera_visual_overrides(),
},
),
).remappings(
[
# No nav stack here, so MovementManager's goal/way_point/stop_movement
Expand Down
62 changes: 62 additions & 0 deletions dimos/experimental/robot/bosdyn/spot/blueprints/spot_replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2025-2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Replay the latest Spot recording to Rerun — no robot, runs anywhere.

`SpotReplay` plays a memory2 recording (from `spot-record`) back onto the same
stream names `SpotHighLevel` uses, so the Spot camera layout and per-camera
frustums light up in the Rerun 3D view. The `visual_override` routes the two
shared CameraInfo streams onto each camera's image entity so every frustum
anchors to its optical frame.

Usage:
# the shared recording, pulled from LFS on first run:
dimos run spot-replay --db-path=spot_small_loop.db
# newest *.db under ~/datasets/spot:
dimos run spot-replay
# a specific recording:
dimos run spot-replay --db-path=/path/to/spot.db
"""

from __future__ import annotations

from dimos.core.coordination.blueprints import autoconnect
from dimos.core.global_config import global_config
from dimos.experimental.robot.bosdyn.spot.replay import SpotReplay
from dimos.experimental.robot.bosdyn.spot.rerun import (
spot_body_static_overrides,
spot_camera_layout,
spot_camera_visual_overrides,
)
from dimos.mapping.odometry_path import OdometryPath
from dimos.protocol.pubsub.impl.lcmpubsub import LCM
from dimos.visualization.rerun.bridge import RerunBridgeModule
from dimos.visualization.rerun.websocket_server import RerunWebSocketServer

# Compose only the Rerun bridge (+ its websocket server) directly instead of the
# shared `vis_module`: replay just needs the 3D viewer, and `vis_module` also
# bundles the WebsocketVisModule, which auto-opens the 7779 Command Center tab.
spot_replay = autoconnect(
SpotReplay.blueprint(),
OdometryPath.blueprint(),
RerunBridgeModule.blueprint(
pubsubs=[LCM()],
rerun_open=global_config.rerun_open,
rerun_web=global_config.rerun_web,
blueprint=spot_camera_layout,
visual_override=spot_camera_visual_overrides(),
static=spot_body_static_overrides(),
),
RerunWebSocketServer.blueprint(),
).remappings([(OdometryPath, "path", "odom_path")])
171 changes: 171 additions & 0 deletions dimos/experimental/robot/bosdyn/spot/replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Copyright 2025-2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Replay a recorded Spot session to streams, mirroring `SpotHighLevel`'s outputs.

Opens a memory2 SQLite recording (written by `SpotRecorder`) and replays every
camera, depth, and odometry stream onto Out ports named exactly like
`SpotHighLevel`'s, so the same Rerun visualization wires up by name — no robot
required. The recorded ``tf`` tree (odom->base_link plus the base_link->camera
mounts) is republished so every frame stays spatially anchored in 3D.

The travelled trail is not built here: ``odometry`` feeds ``OdometryPath``,
which accumulates it and is equally happy on a live robot.
"""

from __future__ import annotations

from collections.abc import AsyncIterator
import math
from pathlib import Path

from dimos.core.module import Module, ModuleConfig
from dimos.core.stream import Out
from dimos.experimental.robot.bosdyn.spot.config import (
CAMERA_STREAM_SUFFIXES,
FRONT_CAMERA_ROTATE_UPRIGHT,
)
from dimos.memory2.replay import resolve_db_path
from dimos.memory2.store.sqlite import SqliteStore
from dimos.msgs.geometry_msgs.Quaternion import Quaternion
from dimos.msgs.geometry_msgs.Transform import Transform
from dimos.msgs.geometry_msgs.Vector3 import Vector3
from dimos.msgs.nav_msgs.Odometry import Odometry
from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo
from dimos.msgs.sensor_msgs.Image import Image
from dimos.msgs.tf2_msgs.TFMessage import TFMessage
from dimos.utils.logging_config import setup_logger

logger = setup_logger()

# Image Out ports to replay, matching SpotHighLevel / SpotRecorder stream names.
_IMAGE_STREAMS = [
f"{kind}_image_{suffix}" for kind in ("grayscale", "depth") for suffix in CAMERA_STREAM_SUFFIXES
]
_PLAYBACK_STREAMS = [*_IMAGE_STREAMS, "grayscale_info", "depth_info", "odometry"]

# SpotHighLevel rights the sideways front camera images but leaves their optical
# tf frames at the raw mount orientation, so the 3D frustum + depth
# back-projection land rotated. Roll each front optical frame about its viewing
# (z) axis to bring it back upright. The two front cameras mount mirror-imaged,
# so frontright sits a half turn (2 quarter turns) past frontleft.
_HALF_TURN_QUARTERS = 2
_OPTICAL_FRAME_ROLL_TURNS = {
"frontleft_camera_optical": FRONT_CAMERA_ROTATE_UPRIGHT,
"frontright_camera_optical": FRONT_CAMERA_ROTATE_UPRIGHT + _HALF_TURN_QUARTERS,
}


class SpotReplayConfig(ModuleConfig):
"""Where to read the recording from and how to play it back."""

# An on-disk path, or the name of a dataset to pull from LFS (e.g.
# ``spot_small_loop.db``). Empty -> newest ``*.db`` in ``dataset_dir``.
db_path: str = ""
dataset_dir: str = "~/datasets/spot"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, not sure ~/datasets is the best place. I don't like it when programs mess with my home dir without asking. Maybe this should use STATE_DIR?


speed: float = 1.0
loop: bool = True
seek: float | None = None
duration: float | None = None

# Roll the front optical tf frames upright at replay time. Off by default:
# recordings are expected to already store upright front frames. Turn on to
# view an old recording whose tf still holds the raw sideways mount.
roll_front_frames: bool = False


class SpotReplay(Module):
"""Replays Spot's fisheye + depth cameras, odometry, and tf from a recording."""

config: SpotReplayConfig
dedicated_worker = True

grayscale_image_front_left: Out[Image]
grayscale_image_front_right: Out[Image]
grayscale_image_left: Out[Image]
grayscale_image_right: Out[Image]
grayscale_image_back: Out[Image]

depth_image_front_left: Out[Image]
depth_image_front_right: Out[Image]
depth_image_left: Out[Image]
depth_image_right: Out[Image]
depth_image_back: Out[Image]

grayscale_info: Out[CameraInfo]
depth_info: Out[CameraInfo]

odometry: Out[Odometry]
tf: Out[TFMessage]

def _resolve_db_path(self) -> Path:
if self.config.db_path:
return resolve_db_path(Path(self.config.db_path).expanduser())
directory = Path(self.config.dataset_dir).expanduser()
recordings = sorted(directory.glob("*.db"), key=lambda path: path.stat().st_mtime)
if not recordings:
raise FileNotFoundError(f"No .db recordings found in {directory}")
return recordings[-1]

def _republish_tf(self, message: TFMessage) -> None:
self.tf.publish(
TFMessage(*(self._roll_optical_frame(transform) for transform in message.transforms))
)

def _roll_optical_frame(self, transform: Transform) -> Transform:
if not self.config.roll_front_frames:
return transform
turns = _OPTICAL_FRAME_ROLL_TURNS.get(transform.child_frame_id)
if not turns:
return transform
roll = Quaternion.from_euler(Vector3(0.0, 0.0, turns * math.pi / 2))
return Transform(
translation=transform.translation,
rotation=transform.rotation * roll,
frame_id=transform.frame_id,
child_frame_id=transform.child_frame_id,
ts=transform.ts,
)

async def main(self) -> AsyncIterator[None]:
db_path = self._resolve_db_path()
logger.info(f"Replaying Spot recording from {db_path}")

store = SqliteStore(path=str(db_path), must_exist=True)
store.start()
self.register_disposable(store)

replay = store.replay(
speed=self.config.speed,
loop=self.config.loop,
seek=self.config.seek,
duration=self.config.duration,
)
available = set(replay.list_streams())

for name in _PLAYBACK_STREAMS:
if name not in available:
logger.warning(f"Spot replay: stream {name!r} missing from recording; skipping")
continue
self.register_disposable(
replay.stream(name).observable().subscribe(getattr(self, name).publish)
)

if "tf" in available:
self.register_disposable(replay.stream("tf").observable().subscribe(self._republish_tf))
else:
logger.warning("Spot replay: no tf stream in recording; 3D frames will be missing")

yield
Loading
Loading