control: per-episode hardware lifecycle driven by SessionManager#248
Conversation
2db562f to
1c6401a
Compare
Add opt-in per-episode lifecycle hooks (on_pre_episode / on_episode_started / on_episode_ended) to HardwareComponent, gated by a virtual is_episode_lifecycle_enabled() that components answer from their own config. Producers expose their backing component (PolledProducer/PushProducer::hardware()), set once by the producer registries, so the SessionManager can reach it. The SessionManager owns the whole choreography: register controllers via add_teleop(), then per episode it pauses teleop mirrors, runs each opted-in component's on_pre_episode() (arms re-home to staged_position), re-arms teleop, starts mirroring when recording goes live, resets between episodes, and stops teleop at shutdown. TrossenArmComponent implements on_pre_episode()->stage() and parses episode_lifecycle_enabled; rename teleop_moving_time_s to slew_time_s; arm holds pose on teleop stop and the control thread is joined on restart.
Hand teleop controllers to the SessionManager via add_teleop() and drop the per-episode pause/stage/re-arm, mirror start, reset, and stop_teleop choreography the manager now owns. Staging is opt-in per arm via episode_lifecycle_enabled (solo enables it alongside its staged_position); docs updated accordingly.
Update ArmConfig staging-field tests for the slew_time_s rename and the TeleopController tests for pause_teleop; drop the obsolete SdkConfig stage_each_episode cases.
reset_teleop() and components' on_episode_ended() run on the discard/re-record path as well as on clean stop, so teleop and components land in a consistent between-episode state either way; user episode-ended callbacks remain clean-stop only. The lifecycle participants are snapshotted under episode_mutex_ and the hooks run after unlocking (matching the observers/callbacks discipline; no hardware calls under the lock). Document the producer-backing precondition on is_episode_lifecycle_enabled() (a component participates only if it backs a registered producer).
Each opted-in component's on_pre_episode() drives independent hardware and stage() is a blocking point-to-point move, so run them concurrently via std::async. Episode-boundary latency is now bounded by the slowest component rather than the sum. Futures rethrow on get() and join on destruction, so a hook exception still propagates and no staging thread is orphaned.
1c6401a to
af66c59
Compare
There was a problem hiding this comment.
Pull request overview
This PR moves per-episode teleop + hardware “staging” orchestration out of the example applications and into the SDK by having SessionManager drive an opt-in per-episode lifecycle on producer-backed HardwareComponents, while also tightening the teleop controller’s thread lifecycle to support repeated start/stop across episodes.
Changes:
- Introduces an opt-in per-episode lifecycle API on
HardwareComponentand hasSessionManagerorchestrate pause/stage/re-arm + started/ended hooks (with parallel pre-episode staging viastd::async). - Extends producers/registries to retain and expose their backing
HardwareComponentso the manager can find lifecycle participants without per-producer glue. - Updates teleop + arm behavior and configuration:
pause_teleop(), stale-thread reaping on restart, arm staging viaon_pre_episode(), hold-pose on teleop stop, and config/tests/examples updated accordingly.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
include/trossen_sdk/hw/hardware_component.hpp |
Adds per-episode lifecycle hooks + opt-in gate. |
include/trossen_sdk/hw/producer_base.hpp |
Producers retain/expose backing HardwareComponent via hardware(). |
src/runtime/producer_registry.cpp |
Sets the producer’s backing hardware component at creation time. |
src/runtime/push_producer_registry.cpp |
Sets the push producer’s backing hardware component at creation time. |
include/trossen_sdk/runtime/session_manager.hpp |
Adds add_teleop() and declares lifecycle-component collection helper. |
src/runtime/session_manager.cpp |
Orchestrates per-episode lifecycle (pause teleop, parallel pre-episode hooks, teleop start, episode end/reset, shutdown stop). |
include/trossen_sdk/hw/teleop/teleop_controller.hpp |
Adds pause_teleop() API and documents behavior. |
src/hw/teleop/teleop_controller.cpp |
Implements pause_teleop() and joins stale threads on restart. |
include/trossen_sdk/hw/arm/trossen_arm_component.hpp |
Adds episode lifecycle opt-in + renames timing parameter to slew_time_s. |
src/hw/arm/trossen_arm_component.cpp |
Implements on_pre_episode() staging, blocking stage move, and hold-pose on teleop stop. |
include/trossen_sdk/configuration/types/hardware/arm_config.hpp |
Adds staging/lifecycle/timing fields to arm config JSON round-trip. |
tests/test_config.cpp |
Adds ArmConfig JSON parsing/round-trip/omission tests. |
tests/test_teleop_controller.cpp |
Adds regression tests for teleop restart after exception and pause/resume behavior. |
examples/trossen_stationary_ai/trossen_stationary_ai.cpp |
Switches to SessionManager::add_teleop() and removes manual episode choreography. |
examples/trossen_solo_ai/trossen_solo_ai.cpp |
Switches to SessionManager::add_teleop() and removes manual episode choreography. |
examples/trossen_mobile_ai/trossen_mobile_ai.cpp |
Switches to SessionManager::add_teleop() and removes manual episode choreography. |
examples/trossen_stationary_ai/README.md |
Documents opt-in per-episode home staging behavior. |
examples/trossen_solo_ai/README.md |
Documents opt-in per-episode home staging behavior. |
examples/trossen_mobile_ai/README.md |
Documents opt-in per-episode home staging behavior. |
examples/trossen_stationary_ai/config.json |
Formatting-only change (blank line). |
examples/trossen_mobile_ai/config.json |
Formatting-only change (blank line). |
examples/trossen_solo_ai/config.json |
Updates arm config keys to slew_time_s + enables episode lifecycle in the example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (j.contains("slew_time_s")) { | ||
| j.at("slew_time_s").get_to(c.slew_time_s); | ||
| } |
| if (config.contains("slew_time_s")) { | ||
| slew_time_s_ = config.at("slew_time_s").get<float>(); | ||
| if (slew_time_s_ < 0.0f || !std::isfinite(slew_time_s_)) { | ||
| throw std::runtime_error( | ||
| "TrossenArmComponent: 'teleop_moving_time_s' must be non-negative and finite"); | ||
| "TrossenArmComponent: 'slew_time_s' must be non-negative and finite"); | ||
| } | ||
| } |
| // SDK-driven per-episode preparation (runs after user pre-episode callbacks, so it | ||
| // is skipped if the episode was aborted above). Pause teleop mirrors so staging | ||
| // cannot fight the control loop, run each opted-in component's pre-episode hook | ||
| // (e.g. arms re-home to their staged pose), then re-arm teleop modes before the | ||
| // mirror restarts at episode start. | ||
| for (auto& ctrl : teleop_controllers_) ctrl->pause_teleop(); | ||
| // Run the components' pre-episode hooks in parallel. Each drives independent | ||
| // hardware (its own arm/driver), and staging is a blocking point-to-point move, so | ||
| // running them concurrently bounds the episode-boundary wait by the slowest component | ||
| // instead of the sum. Futures rethrow on get() and join on destruction, so a hook | ||
| // exception still propagates and no staging thread is left orphaned. | ||
| { | ||
| std::vector<std::future<void>> staging; | ||
| for (auto& comp : collect_lifecycle_components_()) { | ||
| staging.push_back(std::async(std::launch::async, [comp]() { comp->on_pre_episode(); })); | ||
| } | ||
| for (auto& f : staging) f.get(); | ||
| } | ||
| for (auto& ctrl : teleop_controllers_) ctrl->prepare_teleop(); |
| ctrl.teleop(); | ||
| std::this_thread::sleep_for(std::chrono::milliseconds(50)); | ||
| EXPECT_FALSE(ctrl.is_running()); | ||
|
|
||
| // Restart with no intervening pause_teleop()/stop_teleop(): must reap the | ||
| // stale joinable thread instead of terminating. | ||
| ctrl.teleop(); | ||
| std::this_thread::sleep_for(std::chrono::milliseconds(50)); | ||
| EXPECT_FALSE(ctrl.is_running()); |
| // Staging to a home pose is driven by the application (see the examples' | ||
| // on_pre_episode hook), not the controller, so that it happens before the | ||
| // mirror loop becomes active and can be re-run per episode when teleop is | ||
| // disabled. |
| { | ||
| "robot_name": "trossen_mobile_ai", | ||
|
|
||
|
|
| /// Longer = slower, gentler motion. Sized so that a large start-to-goal | ||
| /// difference does not exceed joint velocity limits or produce violent | ||
| /// motion. Optional; 2.0s is a safe default for the supported arms. | ||
| float slew_time_s{2.0f}; |
There was a problem hiding this comment.
I don't think slew time makes much sense as the config name here. Don't we already have something like this? teleop time or similar? staging_time_s might also work
There was a problem hiding this comment.
We had teleop time but it wasn't fitting well as it wasn't actually used for teleoperation. I'll change it to staging
| * Used to re-stage robots to their home pose between episodes without tearing | ||
| * down teleop. | ||
| */ | ||
| void pause_teleop(); |
There was a problem hiding this comment.
How is this different from reset_teleop?
There was a problem hiding this comment.
reset_teleop() calls post_episode() and keeps the mirror loop running (reposition the leader while recording is stopped); pause_teleop() joins the control thread (stops the mirror) but does not call end_teleop(), so drivers stay armed for re-staging between episodes.
- session_manager: catch exceptions from parallel staging hooks and route through cleanup_and_abort(), matching the rest of start_episode()'s error handling instead of letting a hook throw escape the function - arm/config: rename slew_time_s -> staging_time_s (clearer; Luke's review). teleop_moving_time_s -> staging_time_s is a clean break, not aliased - teleop_controller: fix stale comment that pointed at the examples' hook; staging is now SDK-driven via HardwareComponent::on_pre_episode() - test_teleop_controller: replace fixed sleep_for waits with a bounded poll so the exception-restart tests are deterministic on slow CI - examples: keep configs at main's baseline (no pre-enabled episode_lifecycle_enabled); READMEs document the opt-in
- session_manager: catch exceptions from parallel staging hooks and route through cleanup_and_abort(), matching the rest of start_episode()'s error handling instead of letting a hook throw escape the function - arm/config: rename slew_time_s -> staging_time_s (clearer; Luke's review). teleop_moving_time_s -> staging_time_s is a clean break, not aliased - teleop_controller: fix stale comment that pointed at the examples' hook; staging is now SDK-driven via HardwareComponent::on_pre_episode() - test_teleop_controller: replace fixed sleep_for waits with a bounded poll so the exception-restart tests are deterministic on slow CI - examples: ship configs with no staging setup (no episode_lifecycle_enabled, staged_position, or staging_time_s); READMEs document the opt-in
d3af5ba to
1d1b28a
Compare
Expand the 'Optional: home staging' section in the solo, stationary, and mobile example READMEs with a ready-to-paste snippet (the demo staged_position we used), explicit placement (hardware -> arms -> <arm_name> in config.json), an in-context arm block, and a field reference for staged_position / staging_time_s / episode_lifecycle_enabled.

Summary
Moves the per-episode teleop + staging choreography out of the example apps and into the SDK: the
SessionManagernow owns a per-episode hardware lifecycle, components opt in and implement their own per-episode work, and the three AI examples collapse to a singleadd_teleop()registration. Supersedes #246.Changes
include/trossen_sdk/hw/hardware_component.hpp: add opt-in, no-op per-episode lifecycle hookson_pre_episode()/on_episode_started()/on_episode_ended()plus a virtualis_episode_lifecycle_enabled()(defaultfalse). The base parses no config — each component answers the getter from its own config. The per-episode lifecycle runs on both a clean stop and a discard/re-record (user episode-ended callbacks stay clean-stop only); a component only participates if it backs a registered producer.include/trossen_sdk/hw/producer_base.hpp+src/runtime/producer_registry.cpp+src/runtime/push_producer_registry.cpp: producers retain their backingHardwareComponentand expose it viahardware(); the registries set it once after construction, so theSessionManagercan reach every component's hooks with no per-producer code.include/trossen_sdk/runtime/session_manager.hpp+src/runtime/session_manager.cpp: addadd_teleop()+ ateleop_controllers_list andcollect_lifecycle_components_()(the unique, opted-in, producer-backed components). Drive the per-episode bracket — pause mirrors →on_pre_episode()(arms re-home) → re-arm in the pre-episode phase;teleop()+on_episode_started()once recording is live;reset_teleop()+on_episode_ended()on episode end;stop_teleop()at shutdown. Opted-in components stage in parallel (independent hardware viastd::async), so the episode-boundary wait is bounded by the slowest arm rather than the sum. A staging hook that throws is caught and routed through the samecleanup_and_abort()path as every otherstart_episode()failure.include/trossen_sdk/hw/arm/trossen_arm_component.hpp+src/hw/arm/trossen_arm_component.cpp: overrideon_pre_episode()→stage(), parseepisode_lifecycle_enabled, and overrideis_episode_lifecycle_enabled(). Renameteleop_moving_time_s→staging_time_s(it governs any SDK-commanded point-to-point move — staging to home and the return-to-rest move — not just teleop). On teleop stop, hold the measured pose before returning to rest so the arm doesn't drop under gravity.include/trossen_sdk/hw/teleop/teleop_controller.hpp+src/hw/teleop/teleop_controller.cpp: renamepause_mirror()→pause_teleop()for naming consistency, and join a stale control thread on restart so repeatedteleop()across episodes cannotstd::terminate.include/trossen_sdk/configuration/types/hardware/arm_config.hpp: carrystaged_position,staging_time_s, andepisode_lifecycle_enabledthroughfrom_json/to_json.examples/trossen_{solo,stationary,mobile}_ai/{trossen_*_ai.cpp,config.json,README.md}: hand controllers to the manager viaadd_teleop()and remove the per-episode pause/stage/re-arm, mirror-start, reset, and stop choreography the manager now owns. Example configs stay opt-out (noepisode_lifecycle_enabled); each README's "Optional: home staging" section documents how to enable staging per arm.Test Plan
make build)test_config,test_teleop_controller, and the session-manager suitepre-commiton changed files — cpplint, codespell, json, whitespace)Breaking Changes
The new lifecycle fields —
episode_lifecycle_enabled,staged_position, andstaging_time_s— are optional and default off/empty, so configs that don't set them load unchanged. One existing key is renamed, not aliased:teleop_moving_time_s→staging_time_s. A config still using the oldteleop_moving_time_skey will have it silently ignored and fall back to the2.0sdefault; update such configs tostaging_time_s. This is a deliberate clean break (pre-1.0, no config-compat promise), not a dual-key alias.ABI is not preserved —
HardwareComponentgains virtuals and the producer bases gain a member; downstream consumers rebuild on upgrade (pre-1.0, no ABI promise). The teleop-stop sequence is a deliberate runtime behavior change — the arm holds position on stop instead of going briefly limp.Related Issues
Relates to TDS-188