Split 7 of 7: command line tools, episode export and the example app - #1798
Split 7 of 7: command line tools, episode export and the example app#1798martien-wdy wants to merge 12 commits into
Conversation
Adds the read-side of the data platform: the wendy data commands, the episode export package and its playable converter, the reference model and campaign examples, and the remaining documentation. Part 7 of 7 in the split of the Wendy Data Platform change. With this chunk the stack is equivalent to feature/wendy-data-platform.
Swift E2E Review
|
| slot["discarded"] += 1 | ||
| slot["frame"] = frame | ||
| state.notify() | ||
| except BaseException as exc: # noqa: BLE001 - re-raised on the consumer thread |
| # Reached only once the stream stayed gone for the whole reconnect | ||
| # budget; frames_with_reconnect has already said why. | ||
| log.info("no more frames from %s; shutting down", source_id) | ||
| except KeyboardInterrupt: |
Moves the reference app's vendored proto copy to proto/wendy/agent/apps/v1/ alongside the canonical move, and updates the Dockerfile codegen path, the Python stub imports, the namespace packages the unit test stubs out, the README, and the path constants in TestExampleSensorProtoMatchesCanonical.
…ce app Follows the entitlement rename in the shared app configuration. The reference application is what an author copies, so it must model the rule rather than the exception: its wendy.json now declares the sensor-read entitlement with an allowlist naming the source it actually subscribes to, v4l2:/dev/video0, and episode-write for its prediction and event records. The README says that the allowlist has to match what `wendy data sources` prints on the device, because a source outside it is neither listed nor subscribable, and records that only camera sources are subscribable in this release. The example's build-time copy of sensor_service.proto is refreshed from the canonical Proto/ file, which the parity test requires.
…ty binding An app that wants to use ordinary tooling (ffmpeg, GStreamer) cannot consume the harness today: the sensors entitlement delivers frames only over gRPC as SensorService.Subscribe, so any such tool needs a bespoke integration. The camera entitlement hands over a raw device node instead, which makes the app an independent second reader, so the frame a model scores is not provably the frame the episode recorded. This adds one producer with two planes. The data plane is a v4l2loopback node fed from the SAME producer hub that episode capture and the gRPC subscribers already consume, read with any standard tool. The control plane is a new SensorService.SubscribeFrameIdentity stream carrying identity only, never pixels. The binding between them is not what the obvious design would suggest. Writing sample_id into v4l2_buffer.sequence is impossible: v4l2loopback's QBUF handler overwrites the sequence of any queued output buffer with its own write_position counter, discarding whatever the writer supplied, and no option disables it. The same handler does copy the buffer back out to userspace after stamping it and before advancing the counter, so the writer learns the sequence the kernel assigned. The binding is therefore inverted: the pump observes the kernel's number and publishes the mapping, which needs no assumption about arrival order. Scope is limited to what is sound. Only whole H.264 access units from the native V4L2 producer are written frame-for-frame; unaligned byte-stream producers are refused, and no decoder is inserted, because decoder reordering would break the one-frame-in-one-frame-out correspondence the binding depends on. The V4L2 OUTPUT writer is UNVERIFIED ON HARDWARE.
A ROS 2 graph has only ever been addressable as a whole DDS domain, so there was no identifier for a single topic and no parser for one. Add the grammar in the data package, which owns episode sources and is the one package both the agent adapter that mints these identifiers and the campaign resolver that matches against them can import. The per-topic form is the domain form with ":" and the topic name appended, "ros2:<rmw>:domain-<n>:<topic>", rather than a shorter shape such as "ros2:<domain>:<topic>". The domain identifier is then literally the prefix of every topic identifier on that domain, so one parser reads both spellings and backwards compatibility is a property of the scheme rather than a branch in the resolver. Keeping the Remote Middleware Interface (RMW) name also keeps the identifier collision free: two RMW implementations can be live on the same DDS domain number and they do not interoperate, so "/chatter" on each is two different topics. The topic is always the last field. ROS 2 graph names admit only letters, digits, underscores and slashes, so a ":" cannot occur inside one and everything after the third ":" is the topic however many slashes it contains.
`wendy data sources` showed one opaque entry per Data Distribution Service (DDS) domain, and a campaign naming it recorded the entire domain because the adapter always ran `ros2 bag record -a`. On a real robot that is every camera topic and every high-rate inertial measurement unit topic whether they were wanted or not, and there was no way to see what the graph even offered without leaving Wendy for ROS 2 tooling. Discover now enumerates the graph and emits one source per topic, with the message type as the Detail so the DETAIL column says what the topic carries. The enumeration reuses the existing `ros2 topic list -t` path, which returns names and types in a single exec, so there is no second enumerator. The domain-level source stays listed as the "record everything on this graph" handle. Start groups the selected sources by domain and runs one recorder per domain: `-a` when the domain-level identifier was selected, otherwise `ros2 bag record <topic>...` for exactly the topics named. One invocation rather than one per topic, because each rosbag2 process pays its own DDS discovery and produces its own bag and its own clock mapping, and splitting a domain across several would leave messages that share one timeline with several independent mappings. Every selected source still gets its own CaptureResult, so the manifest accounts for what the campaign named. Healthy is now derived rather than hardcoded true. A domain whose topic listing fails enumerates as unhealthy with the failure in its Detail, instead of advertising a dead graph as recordable. Discovery is cached with a five second time to live, keyed by sidecar identity and droppable outright through invalidateDiscovery. `ros2 topic list -t` costs about 0.6 seconds beyond the command-line baseline on hardware and does not grow with topic count, and Discover runs once for `wendy data sources` plus twice more for a single campaign trigger. Publisher and subscriber counts are deliberately not fetched: that path is a per-topic `ros2 topic info` exec measured at roughly 0.8 seconds per bounded round, so it would cost ten seconds or more on a hundred-topic robot and answers no question involved in choosing what to record.
Campaign YAML has documented `ros2: <topic>` since the campaign format existed, but ResolveCampaignSources ignored the value and selected every healthy ROS 2 source, which was the whole DDS domain. A plan asking for the lidar and the joint states recorded the entire graph. A topic selector now resolves to that topic's own source on every healthy graph publishing it, so the episode records that topic and nothing else. A full per-topic identifier resolves to that one source. A domain-level identifier, with or without the "ros2:" prefix, resolves to the whole domain and still records with `-a`, as does any unrecognized value, which is exactly what every selector did before. No deployed plan changes meaning except the topic selectors, which now mean what they say. A topic selector naming a topic no healthy graph publishes is an error rather than a fallback to the whole domain. Falling back would restore the behaviour this change exists to remove, and the alternative failure is the one this package already refuses for audio selectors: a sealed, checksummed, uploaded episode holding none of the data the plan asked for.
Two-plane camera access: loopback data plane fed from the producer hub, frame identity over the sensor socket
Per-topic ROS 2 sources: topic-addressable capture, derived health, cached discovery
Problem
See chunk 1 for why pull request #1751 is being split. This is chunk 7 of 7, the last in the stack.
Cause
After chunk 6 a device captures episodes and uploads them, but there is no way for a person to list, download or inspect what it recorded, and no worked example of an app that uses the sensor-read entitlement.
Solution
Adds the read side and the illustrative material:
wendy datacommand line interface commands for listing sources and downloading episodes.go/internal/episodeexport, which converts a stored episode into a playable form, and theepisode-playablecommand that drives it. This package imports nothing else internal.Examples/WendyDataModelApp, a reference app declaring thesensor-readandepisode-writeentitlements, with its Python client helpers and its tests.Examples/WendyDataCampaign, a worked campaign.wendy datacommand reference and the data platform demonstration walkthrough.swift/WendyE2ETestsupdated for the new command surface.Deliberately left to later chunks
Nothing. With this chunk merged the stack is byte for byte equal to
feature/wendy-data-platform, verified with an emptygit diff feature/wendy-data-platform..split/7-tools-and-example.Depends on
Chunk 6 (
split/6-upload), which this pull request is based on.Verification
CC=/usr/bin/clang go build ./...succeeds.CC=/usr/bin/clang go test ./go/internal/cli/commands/... ./go/internal/episodeexport/... ./go/cmd/episode-playable/... ./go/internal/shared/appconfig/... ./go/internal/agent/data/...passes.gofmt -l go/is empty andgo vetis clean for the touched packages.