A Go application that synchronously records multi-modal sensor data:
- Video from RTSP streams (via ffmpeg) — multiple cameras; on the G1 both the front and rear pre-CV streams from the OM1 video-processor
- Audio from RTSP streams (via ffmpeg)
- Video features (capture-time-stamped, video-derived events) from the OM1 video-processor's feature log
- Lidar scans from a CycloneDDS topic
- Point clouds (Livox MID360) from a CycloneDDS topic (zstd-compressed, lossless)
- Depth frames from a CycloneDDS topic (RVL-compressed, lossless)
- Odometry messages from a CycloneDDS topic
- Lowstate (IMU/joints/battery/etc.) from a CycloneDDS topic
All streams are timestamped and organized into session directories for easy alignment and analysis.
- Go 1.25 or later
- ffmpeg installed and available in PATH
- CMake + a C compiler (to build CycloneDDS — see "Build prerequisites" below)
draco_encoderon PATH (optional — only needed for point cloud compression before upload; runmake install-dracoto build it. See "Compressing the large binary streams" below.)
Sensor/state topics (lidar, point cloud, depth, odometry, lowstate) are read by
subscribing directly to the robot's CycloneDDS domain, via a first-party cgo
wrapper (internal/ddscore) — there's no zenoh-bridge-dds hop anymore.
Rather than relying on distro packages (which vary release-to-release and
aren't available everywhere), CycloneDDS is built from source, the same way
for every developer, CI, and Docker: make build / make test / make run
all depend on make install-cyclonedds, which clones
eclipse-cyclonedds/cyclonedds
(releases/0.10.x) and builds+installs it into .cyclonedds/install
(gitignored) if not already present there — a no-op on repeat runs. You need
cmake and a C compiler on PATH; nothing else to install manually.
make build / make test / make run also depend on make idl-gen, which
runs the freshly-built idlc over idl/*.idl to produce the type-support C
sources each stream's dds_reader.go #includes (generated into
internal/ddsgen/, gitignored — regenerate after editing any .idl file;
make does this automatically).
Message schemas: idl/*.idl mirror the exact field layout of the ROS 2 /
Unitree messages published on the robot (sensor_msgs/LaserScan,
sensor_msgs/PointCloud2, sensor_msgs/Image, nav_msgs/Odometry,
unitree_go/msg/LowState, unitree_hg/msg/LowState), authored from
unitreerobotics/unitree_ros2
and ros2/common_interfaces. The
generated C type/topic-descriptor symbol names each dds_reader.go
references (e.g. C.unitree_go_msg_dds__LowState_,
C.unitree_go_msg_dds__LowState__desc) have been confirmed against a real
idlc (CycloneDDS 0.10.5) run and a full make build + make test pass.
Set ROBOT_TYPE to select a built-in recording profile. The profile decides
which sensors are recorded, the robot-specific DDS topic defaults, which
cameras exist, and (for lowstate) which message schema to subscribe with --
unitree_go/msg/LowState for Go2 vs unitree_hg/msg/LowState for G1. It is
defined in code (config/profile.go), so no profile files need to ship with
the binary.
ROBOT_TYPE=g1 |
ROBOT_TYPE=go2 |
|
|---|---|---|
| cameras | front_camera_raw (:8556/raw), rear_camera_raw (:8558/raw), down_camera (:8554/down_camera) |
top_camera (:8556/raw), front_camera, down_camera |
lidar scan |
✅ | ✅ |
| point cloud | ✅ rt/utlidar/cloud_livox_mid360 |
— not published by this robot |
| odometry, lowstate | ✅ | ✅ |
| depth | ✅ D435i, 15 Hz (RVL, lossless) | ✅ |
| down camera | ✅ RealSense RGB via down_camera RTSP, 15 Hz |
✅ |
If ROBOT_TYPE is unset or unrecognized the recorder warns and falls back to
go2.
realsense2_camera_node starts inside om1_sensor on every G1 whether or not
a camera is attached, and logs No RealSense devices were found! when there is
none. The topic still exists and is subscribed successfully — it simply never carries a message, which writes a
0-byte depth_frames.bin every session and leaves the depth heartbeat
permanently broken.
The profile has it enabled because this fleet's G1 has a D435i fitted
(measured 14.9 Hz, 480x270 16UC1, RVL-compressed to ~34% of raw, lossless).
On a G1 without one, set ENABLE_DEPTH=false.
There is no third USB camera on a G1 -- only OM1FRONTCAM and OM1REARCAM.
The downward-facing view is the RealSense's colour image, and om1_sensor's
d435_camera_stream node already publishes it to mediamtx as H.264 (see its
get_rtsp_camera_name, which returns "down_camera"), so it is recorded like
any other camera.
The same image is also on DDS as
rt/camera/realsense2_camera_node/color/image_raw, but as raw rgb8: 305 KB a
frame, 16.5 GB an hour. Re-encoding that here costs about 5x the RTSP stream
(measured: JPEG q80 ~0.96 GB/h against 0.18 GB/h for the H.264 already being
produced) for a picture the robot has encoded anyway. Record the stream.
It 404s on a G1 with no RealSense fitted, since the node has nothing to publish.
It previously carried rt/utlidar/cloud_deskewed behind EnablePointCloud: false — a topic the Go2 does not publish, disabled but still written down,
which read as "flip the switch and it records". The entry is gone. Setting
ENABLE_POINTCLOUD=true on a Go2 now logs that no topic is configured instead
of silently subscribing to nothing; supplying POINTCLOUD_DDS_TOPIC as well
enables it properly.
Any of the following override the selected profile / defaults:
ROBOT_TYPE- Robot profile to load:go2org1(default:go2)ENABLE_LIDAR- Record the 2D/scanlidar (default: from profile)ENABLE_POINTCLOUD- Record the 3D point cloud (default: from profile)ENABLE_COLLECTION- Enable/disable data collection (default:true; set tofalse,0, ornoto disable)ROBOT_PROFILES_FILE- Path to arobots.yamlreplacing the embedded profiles (default: empty — use embedded)ENABLE_DEPTH/ENABLE_ODOM/ENABLE_LOWSTATE- Record these topics (default: from profile)FRONT_CAMERA_RAW_RTSP_URL- G1 front pre-CV stream (default:rtsp://localhost:8556/raw, gst-direct)REAR_CAMERA_RAW_RTSP_URL- G1 rear pre-CV stream (default:rtsp://localhost:8558/raw, gst-direct)TOP_CAMERA_RTSP_URL- Go2 top camera stream URL (default:rtsp://localhost:8556/raw)FRONT_CAMERA_RTSP_URL- Go2 front camera stream URL (default:rtsp://localhost:8554/front_camera)DOWN_CAMERA_RTSP_URL- Go2 down camera stream URL (default:rtsp://localhost:8554/down_camera)AUDIO_RTSP_URL- Audio stream URL (default:rtsp://localhost:8555/live— audio track of the video-processor's muxed session stream, gst-direct)VIDEO_FEATURES_LOG- Path to the video-processor's feature-log JSONL on a shared volume (default: empty — ingestion disabled)LIDAR_DDS_DOMAIN- CycloneDDS domain ID for lidar (default:0)LIDAR_DDS_TOPIC- DDS topic for lidar data (default:rt/scan)POINTCLOUD_DDS_DOMAIN- CycloneDDS domain ID for point cloud (default:0)POINTCLOUD_DDS_TOPIC- DDS topic for point cloud data (default:rt/utlidar/cloud_livox_mid360)DEPTH_DDS_DOMAIN- CycloneDDS domain ID for depth (default:0)DEPTH_DDS_TOPIC- DDS topic for depth frames (default:rt/camera/realsense2_camera_node/depth/image_rect_raw)ODOM_DDS_DOMAIN- CycloneDDS domain ID for odometry (default:0)ODOM_DDS_TOPIC- DDS topic for odometry data (default:rt/odom)LOWSTATE_DDS_DOMAIN- CycloneDDS domain ID for lowstate (default:0)LOWSTATE_DDS_TOPIC- DDS topic for lowstate data (default:rt/lowstate)RECORDINGS_DIR- Base directory for recordings (default:recordings)SESSION_ROTATE_INTERVAL- Close the current session and open a fresh one on this cadence, so each segment can be uploaded without waiting for the whole run to end (default:5m;0disables rotation — one session for the life of the process, as before this feature existed)UPLOAD_ENABLED- Upload each rotated-away session to the openmind-api (default:true; actual uploading additionally requiresOPENMIND_API_URLandOPENMIND_API_KEY— see "Cloud upload" below)OPENMIND_API_URL- Base URL of the openmind-api, e.g.https://<host>/api/core/v1(default: empty — upload stays off, recording-only, until this is set)OPENMIND_API_KEY- API key sent asAuthorization: Bearer <key>(default: empty)DELETE_AFTER_UPLOAD- Delete a session's local files once it has uploaded successfully (default:false— keep everything locally regardless of upload)UPLOAD_MULTIPART_THRESHOLD_BYTES- Files at or above this size go through S3 multipart upload instead of a single presigned POST (default:104857600, 100 MiB)UPLOAD_PART_SIZE_BYTES- Chunk size for multipart uploads (default:16777216, 16 MiB)UPLOAD_CONCURRENCY- How many of a session's files upload at the same time, instead of one at a time sharing the session's whole upload deadline (default:4)RETENTION_MAX_BYTES- Hard cap onRECORDINGS_DIR's total size; once exceeded, sessions are deleted oldest first (preferring already-uploaded ones, but not-yet-uploaded ones too if that's what it takes) until it isn't (default:107374182400, 100 GiB;0disables cap enforcement — see "Retention & the catch-up/cap sweep" below)RETENTION_SWEEP_INTERVAL- How often the retention sweep looks for finished-but-not-yet-uploaded sessions and, if over the cap, deletes uploaded ones (default:5m)SCHEDULE_FILE- Path to a daily recording/uploading schedule (default: empty — no schedule; recording and uploading stay on continuously, exactly as if this feature didn't exist — see "Scheduling" below)
CycloneDDS domain IDs (not endpoints/addresses) are how independent DDS
"networks" on the same host/subnet are separated — leave at the default 0
unless the robot's network config uses a non-default domain. To reach the
robot on a specific network interface, or a non-default domain, set
CYCLONEDDS_URI per CycloneDDS's config
documentation rather
than anything in this table — it's read directly by libddsc, not this
recorder.
make buildThe first run builds CycloneDDS from source (see "Build prerequisites"
above) — a few minutes; subsequent runs reuse the cached .cyclonedds/install.
The binary will be created at bin/om1-telemetry.
./bin/om1-telemetryOr with custom settings:
ROBOT_TYPE=g1 \
ENABLE_COLLECTION=true \
FRONT_CAMERA_RAW_RTSP_URL="rtsp://localhost:8556/raw" \
REAR_CAMERA_RAW_RTSP_URL="rtsp://localhost:8558/raw" \
AUDIO_RTSP_URL="rtsp://localhost:8555/live" \
VIDEO_FEATURES_LOG="/shared/video-processor/features.jsonl" \
LIDAR_DDS_TOPIC="rt/scan" \
POINTCLOUD_DDS_TOPIC="rt/utlidar/cloud_livox_mid360" \
DEPTH_DDS_TOPIC="rt/camera/realsense2_camera_node/depth/image_rect_raw" \
ODOM_DDS_TOPIC="rt/odom" \
LOWSTATE_DDS_TOPIC="rt/lowstate" \
RECORDINGS_DIR="/path/to/recordings" \
./bin/om1-telemetryThe recommended way to run this in production is docker compose, not a
hand-assembled docker run:
cp .env.example .env # then fill in ROBOT_TYPE / OPENMIND_API_URL / OPENMIND_API_KEY / RECORDINGS_DIR_HOST
docker compose up -d --builddocker-compose.yml is the canonical reference deployment — it's what sets
--network host (required: the RTSP/DDS sources this recorder subscribes to
are only reachable on the host's own loopback/network namespace, not a
container-private one), the recordings volume, and a stop_grace_period
long enough for an in-flight upload to finish before shutdown. The optional
volumes (SCHEDULE_FILE, /run/systemd/timesync, /etc/localtime) are
commented out in the file with notes on when to enable each — see
"Scheduling" below for the two that affect it.
RECORDINGS_DIR itself doesn't need setting: the Dockerfile bakes in
/app/recordings to match the compose file's mount point, so you only need
to point RECORDINGS_DIR_HOST (in .env) at wherever you want that data to
land on the host.
If you'd rather not use Compose, docker build -t om1-telemetry . plus a
docker run reproducing the same flags works identically — docker-compose.yml
is just that command written down once instead of retyped per deployment.
Each recording session creates a timestamped directory structure:
recordings/
└── 2026-05-15/
└── 2026-05-15_14-30-00/
├── meta.json # Session metadata (start/end time, boot id, clock state)
├── clock_timebase.jsonl # Monotonic<->UTC journal; see "Recording without a clock"
├── front_camera_raw.mp4 # G1: front pre-CV camera recording
├── rear_camera_raw.mp4 # G1: rear pre-CV camera recording
├── front_camera_raw_frames.csv # Per-frame: ...,wallclock_unix_ns,mono_ns
├── audio.ogg # Audio recording
├── video_features.jsonl # Verbatim video-processor feature events (if VIDEO_FEATURES_LOG set)
├── video_features_timestamps.csv # unix_ns,t_capture_ns,type,seq (capture time)
├── video_features_timebase.json # monotonic<->UTC mapping from the log header
├── lidar_scans.bin # Raw lidar point cloud data
├── lidar_timestamps.csv # Timestamps: unix_ns,seq,byte_offset,mono_ns
├── pointcloud_frames.bin # zstd-compressed Livox MID360 PointCloud2 frames
├── pointcloud_timestamps.csv # unix_ns,seq,byte_offset,byte_length,method,mono_ns
├── depth_frames.bin # RVL-compressed depth frames (lossless)
├── depth_timestamps.csv # unix_ns,seq,byte_offset,byte_length,method,width,height,encoding,mono_ns
├── odom_frames.bin # Raw odometry messages
└── odom_timestamps.csv # Timestamps: unix_ns,seq,byte_offset,mono_ns
Every timestamps CSV carries a mono_ns column alongside its wall-clock
column. On a Go2, top_camera.mp4 / front_camera.mp4 / down_camera.mp4
appear instead of the two G1 raw streams.
Each row in depth_timestamps.csv slices one frame out of depth_frames.bin
using byte_offset and byte_length. Depth is compressed with RVL
(Wilson, Fast Lossless Depth Image Compression, ISS 2017) — a lossless codec
designed for 16-bit depth maps, so depth values are preserved exactly while
typically using far less space than the raw frames.
The extra columns describe each frame so it can be reconstructed offline:
method—rvlfor RVL-compressed frames, orrawfor the fallback (see below).width,height— frame dimensions; the decoded frame haswidth*height16-bit pixels.encoding— the source ROS image encoding (e.g.16UC1).
To decode a frame: read byte_length bytes at byte_offset, then RVL-decode
into width*height little-endian uint16 pixels.
Fallback: if a payload can't be parsed as a 16-bit depth image, it is stored
verbatim with method=raw (the original serialized sensor_msgs/Image), so no
data is ever lost — those rows are decoded by parsing the ROS message directly.
Each row in pointcloud_timestamps.csv slices one frame out of
pointcloud_frames.bin using byte_offset and byte_length. Each
sensor_msgs/PointCloud2 payload is compressed independently with zstd
(lossless), so frames stay randomly accessible and decode exactly.
method—zstdfor compressed frames, orrawfor the fallback.byte_length— the number of bytes the frame occupies in the data file.
To decode a frame: read byte_length bytes at byte_offset, then zstd-decode
(for method=zstd) to recover the original serialized sensor_msgs/PointCloud2.
Fallback: if compression wouldn't shrink a payload, it is stored verbatim
with method=raw, so no data is ever lost.
A robot that boots away from a network does not know the time. The G1's RTC is
the PMIC's and is not battery-backed, so a cold boot comes up at whatever was
last persisted — days stale in the worst case — and with no network, NTP never
corrects it. docker.service is ordered After=time-set.target, which only
means the clock has been set to something; it is not time-sync.target. So
the recorder can and does start while the clock is wrong.
Previously that meant one time.Now() at startup named the session directory,
NTP corrected the clock seconds later, and every subsequent byte was written
into a directory dated days in the past. Timestamps inside the files jumped
mid-session too, leaving them non-monotonic.
The recorder now separates the two clocks:
unix_ns— the wall clock. Wrong while the robot is offline.mono_ns—CLOCK_BOOTTIME. Never jumps when NTP steps the clock, and keeps counting across suspend. Correct from the first row.
While the clock is untrustworthy the session is written to
recordings/pending/<boot-id>_<uptime-ms>/, named from facts that hold
regardless of the date, and recorders write through a recordings/.current
symlink. clock_timebase.jsonl journals a record at startup, on every clock
step, and once a minute.
When NTP synchronizes, everything is corrected automatically, with no interruption to any recorder:
- The step is journaled with the exact offset.
- The true session start is back-computed along the monotonic timeline.
- The directory is renamed to its real date, and the symlink repointed — atomically. Open file descriptors follow the inode; new segments open through the symlink and land in the new directory. Neither notices.
If the process dies before that (crash, power cut), the next start sweeps
pending/ and dates any session whose journal contains a synchronized record.
A session that is never online from boot to shutdown stays in pending/,
permanently. Its monotonic timeline ended with that boot, so no later sync can
date it — the information does not exist. Leaving it undated is deliberate;
inventing a date would be worse. Installing fake-hwclock on the host bounds
that error to "since the last shutdown" instead of "since whenever".
Detecting all this needs /run/systemd/timesync mounted read-only into the
container (see om1_telemetry.yml). Without it the recorder cannot distinguish
a stale clock from a good one and behaves exactly as it did before — dating
every session directly.
align_recording.py and verify_recording.py apply the correction when they
read a session, so you normally do not need to do anything. The CSVs on disk are
left as recorded on purpose: the recording should say what was observed.
For a downstream tool that reads unix_ns directly:
./script/fix_session_time.py /path/to/session # report only
./script/fix_session_time.py /path/to/session --write # corrected copies
./script/fix_session_time.py /path/to/session --in-place # rewrite, originals kept in <col>_recorded
./script/fix_session_time.py recordings/ --all # sweep every sessionThe correction is a per-row offset — the sum of every step that happened after
that row was written — not a recomputed value. That preserves what each column
meant: a Zenoh row's unix_ns is the publisher's stamp and mono_ns is when
this recorder received it, so the transport latency between them survives.
The recorder captures data on a common UTC-nanosecond clock so that
align_recording.py can nearest-neighbour join every modality. The DDS
streams (lidar, point cloud, depth, odometry, lowstate) carry a source
timestamp — precise (CycloneDDS's dds_sample_info_t.source_timestamp,
nanoseconds since the Unix epoch). The camera/audio streams, however, are
anchored to the ffmpeg record-start wall clock plus each segment's PTS
offset, so they are skewed from true capture by the RTSP/pipeline latency at
connect time.
To recover a capture-accurate video timeline, point VIDEO_FEATURES_LOG at
the OM1 video-processor's feature-log JSONL. The video-processor stamps every
feature event (VVAD/speaking today; pose, recognition, etc. later) at
frame-capture on its shared CLOCK_MONOTONIC, and writes a monotonic→UTC
mapping in a header record. This recorder tails that file and emits
video_features_timestamps.csv keyed to true capture UTC time — directly
comparable to the source-stamped DDS streams, without relying on fragile A/V
stream synchronisation.
Deployment requirement: the feature log is written inside the video-processor
container, so its file must live on a volume shared with this recorder, and
VIDEO_FEATURES_LOG here must point at the same path the video-processor's
--feature-log / OM_FEATURE_LOG writes. On the first open, the recorder skips
the pre-session backlog and records only events from session start; it follows
rotation of the source file.
If you need frame-accurate timing for the recorded video pixels (not just
the feature events) — e.g. to align a specific mp4 frame to a lidar scan within
a few ms — the camera recorder would need to read per-frame capture time from
RTP/RTCP sender reports (e.g. via a gortsplib-based client) instead of the
ffmpeg record-start anchor. That is a larger change and is intentionally not
done here; the feature-log timeline covers the common case.
SESSION_ROTATE_INTERVAL (default 5m) closes the current session and
opens a new one on that cadence, so each segment can be uploaded as it
finishes rather than waiting for the whole run.
Set OPENMIND_API_URL and OPENMIND_API_KEY to upload each closed segment
to the openmind-api. Uploading
runs in the background; a failed upload leaves the local files in place and
retries automatically. UPLOAD_CONCURRENCY (default 4) controls how many
files in a segment upload at once, and DELETE_AFTER_UPLOAD (default
false) removes local files once a segment is confirmed uploaded.
Without both env vars set, the recorder still rotates and records locally — it just doesn't upload.
Before a closed session uploads, internal/upload converts its JSONL logs
to JSON (the upload bucket doesn't allow .jsonl) and, via
internal/compress, compresses the larger binary streams (lowstate,
odom, lidar, depth, pointcloud) — only the compressed files are
uploaded.
lowstate/odom/lidar/depth use zstd, which is lossless, so their
originals are simply deleted once compression succeeds. pointcloud uses
Draco, which quantizes point positions and so is lossy — its original is
kept locally under raw/ instead of being deleted.
Pointcloud compression needs draco_encoder on PATH — run make install-draco, or use the Docker image, which already builds it in. If
it's missing, that one stream just uploads uncompressed instead of failing.
The rotation and shutdown upload paths above only ever handle the one
segment that just closed, and never retry it if that upload fails — a robot
that's offline for an hour, or a run that crashes mid-upload, would
otherwise leave that data stuck locally forever. internal/retention
runs a separate sweep, every RETENTION_SWEEP_INTERVAL, that:
- If upload is configured, walks every closed session directory, oldest
first, and uploads any that were never confirmed uploaded — the same
idempotent
UploadSessioncall rotation uses, so a partially-uploaded segment resumes rather than duplicating. A directory is marked uploaded (a.uploadedfile inside it, never itself uploaded) only after that call succeeds. - If
RECORDINGS_DIR's total size is still overRETENTION_MAX_BYTESafterward, deletes directories oldest first until it isn't — preferring already-uploaded ones, but falling through to not-yet-uploaded ones if deleting only uploaded directories isn't enough (or none exist, e.g. upload isn't configured at all).
It never touches the session currently being recorded to, or whichever
segment still physically holds the live clock_timebase.jsonl journal (see
"Interaction with clock trust" below). Otherwise, RETENTION_MAX_BYTES is a
hard cap: a robot that's been offline, or has upload turned off entirely,
still gets its oldest data deleted rather than filling its disk and
silently stopping recording — losing the oldest, least-recoverable segment
is treated as better than that.
Each rotated segment is dated (or left in pending/) the same way the very
first session is -- see "Recording without a clock" above -- based on the
clock's current sync state, not the state at process boot: once NTP has
synchronized, every later segment is dated directly even on a run that
booted offline. Only the segment that is actually live at the moment the
clock synchronizes gets promoted out of pending/; an earlier segment
rotated away before that moment stays undated, on purpose, for the same
reason a pending/ session from a boot that was never online stays undated
(see above): its monotonic timeline has nothing to anchor it to UTC.
clock_timebase.jsonl is a single, boot-relative journal (one clock, one
set of step/sync events for the whole process), not a per-segment one -- it
physically lives in the first session's directory. Every later segment gets
its own copy of it (a snapshot taken when that segment closes), so each
segment's directory stays self-contained for align_recording.py /
fix_session_time.py without needing the boot session alongside it.
SCHEDULE_FILE points at an optional YAML file describing a daily
recording/uploading schedule — see config/schedule.example.yaml for the
format. Unset by default: recording and uploading stay on continuously. In
docker-compose.yml, enabling it means uncommenting both the SCHEDULE_FILE
environment line and its matching schedule.yaml volume mount.
The schedule's start/end times are evaluated in the container's own
local time, which by default is UTC (no time zone configured) -- an
unmodified schedule.yaml should be written in UTC. Uncommenting the
/etc/localtime volume mount too gives the container the host's real local
time instead, the same way this robot's other containers (e.g. om1)
already get it, so the schedule can be written in local time too.
This only changes how the schedule's own start/end comparison is evaluated. Session directory names and every recorded timestamp are always UTC regardless of the container's local time.
A schedule file that fails to load or parse is treated the same as
SCHEDULE_FILE being unset.
Run the test suite:
make testRun tests for a specific package:
make test- Linting:
make lint(requires golangci-lint) - Tidy dependencies:
make tidy