1use std::collections::HashMap;
14use std::f64::consts::PI;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::{Arc, Mutex, Weak};
17use std::thread::{self, JoinHandle};
18use std::time::{Duration, Instant};
19
20use uuid::Uuid;
21
22use crate::action::v1::{
23 MultiWaypointNavigation, MultiWaypointNavigationFeedback, MultiWaypointNavigationGoal,
24 MultiWaypointNavigationResult, PointNavigation, PointNavigationFeedback, PointNavigationGoal,
25 PointNavigationResult,
26};
27use crate::geometry_msgs::msg::v1::{Pose2D, Twist};
28use crate::robot_bus_interfaces::srv::v1::{Reset, ResetRequest, ResetResponse};
29use crate::runtime::{CallbackGroupType, Context, MultiThreadedExecutor, Node, NodeOptions};
30use crate::{ActionOutcome, BusError, Result};
31
32pub const TANK_PREFIX: &str = "/robot_bus/tank";
34pub const CMD_VEL_TOPIC: &str = "/robot_bus/tank/cmd_vel";
35pub const POSE_TOPIC: &str = "/robot_bus/tank/pose";
36pub const POINT_NAV_ACTION: &str = "/robot_bus/tank/point_navigation";
37pub const MULTI_WAYPOINT_NAV_ACTION: &str = "/robot_bus/tank/multi_waypoint_navigation";
38pub const RESET_SERVICE: &str = "/robot_bus/tank/reset";
39pub const WORLD_SIZE: f64 = 11.0;
40
41const TICK: Duration = Duration::from_millis(50);
42const CMD_TIMEOUT: Duration = Duration::from_millis(100);
45const NAV_LINEAR: f64 = 1.5;
46const NAV_ANGULAR: f64 = 2.2;
47const POS_TOL: f64 = 0.08;
48const YAW_TOL: f64 = 0.08;
49pub const DEFAULT_LEASE: Duration = Duration::from_secs(15);
51pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2);
54
55#[derive(Clone, Debug)]
57pub struct TankEndpoints {
58 pub message_xsub: String,
59 pub message_xpub: String,
60 pub service_frontend: String,
61 pub service_backend: String,
62 pub action_backend: String,
63}
64
65struct SimState {
66 x: f64,
67 y: f64,
68 theta: f64,
69 linear: f64,
70 angular: f64,
71 last_cmd: Instant,
72 navigating: bool,
74 abort_token: u64,
76}
77
78impl Default for SimState {
79 fn default() -> Self {
80 Self {
81 x: WORLD_SIZE / 2.0,
82 y: WORLD_SIZE / 2.0,
83 theta: 0.0,
84 linear: 0.0,
85 angular: 0.0,
86 last_cmd: Instant::now(),
87 navigating: false,
88 abort_token: 0,
89 }
90 }
91}
92
93impl SimState {
94 fn pose(&self) -> Pose2D {
95 Pose2D {
96 x: self.x,
97 y: self.y,
98 theta: self.theta,
99 }
100 }
101
102 fn snap_home(&mut self) {
103 self.abort_token = self.abort_token.wrapping_add(1);
104 self.navigating = false;
105 self.x = WORLD_SIZE / 2.0;
106 self.y = WORLD_SIZE / 2.0;
107 self.theta = 0.0;
108 self.linear = 0.0;
109 self.angular = 0.0;
110 self.last_cmd = Instant::now();
111 }
112}
113
114pub struct TankHandle {
116 stop: Arc<AtomicBool>,
117 ready: Arc<AtomicBool>,
118 join: Option<JoinHandle<()>>,
119}
120
121impl TankHandle {
122 pub fn start(endpoints: TankEndpoints) -> Result<Self> {
124 let stop = Arc::new(AtomicBool::new(false));
125 let ready = Arc::new(AtomicBool::new(false));
126 let stop_flag = Arc::clone(&stop);
127 let ready_flag = Arc::clone(&ready);
128 let join = thread::Builder::new()
129 .name("robot-bus-tank".into())
130 .spawn(move || {
131 if let Err(err) = run_loop(endpoints, stop_flag, ready_flag) {
132 eprintln!("tank exited: {err}");
133 log::error!("tank exited: {err}");
134 }
135 })
136 .map_err(|e| BusError::Protocol(format!("spawn tank: {e}")))?;
137 Ok(Self {
138 stop,
139 ready,
140 join: Some(join),
141 })
142 }
143
144 pub fn wait_ready(&self, timeout: Duration) -> bool {
146 let deadline = Instant::now() + timeout;
147 while Instant::now() < deadline {
148 if self.ready.load(Ordering::Relaxed) {
149 return true;
150 }
151 thread::sleep(Duration::from_millis(20));
152 }
153 self.ready.load(Ordering::Relaxed)
154 }
155
156 pub fn request_stop(&self) {
157 self.stop.store(true, Ordering::Relaxed);
158 }
159
160 pub fn stop(mut self) {
161 self.request_stop();
162 if let Some(handle) = self.join.take() {
163 let _ = handle.join();
164 }
165 }
166}
167
168impl Drop for TankHandle {
169 fn drop(&mut self) {
170 self.stop.store(true, Ordering::Relaxed);
171 if let Some(handle) = self.join.take() {
172 let _ = handle.join();
173 }
174 }
175}
176
177fn run_loop(endpoints: TankEndpoints, stop: Arc<AtomicBool>, ready: Arc<AtomicBool>) -> Result<()> {
178 let mut opts = NodeOptions::tcp();
179 opts.message_xsub = Some(endpoints.message_xsub);
180 opts.message_xpub = Some(endpoints.message_xpub);
181 opts.service_frontend = Some(endpoints.service_frontend);
182 opts.service_backend = Some(endpoints.service_backend);
183 opts.action_backend = Some(endpoints.action_backend);
184
185 let context = Context::new();
186 let executor = MultiThreadedExecutor::with_context(context.clone(), 4);
188 let mut node = Node::with_context_options(&context, "tank", opts);
189 executor.add_node(&mut node)?;
190
191 let pose_pub = node.create_publisher::<Pose2D>(POSE_TOPIC)?;
192 let state = Arc::new(Mutex::new(SimState::default()));
193
194 {
195 let state = Arc::clone(&state);
196 node.create_subscription::<Twist, _>(
197 CMD_VEL_TOPIC,
198 move |twist| {
199 let mut s = state.lock().expect("tank state");
200 if s.navigating {
201 return;
202 }
203 s.linear = twist.linear.as_ref().map(|v| v.x).unwrap_or(0.0);
204 s.angular = twist.angular.as_ref().map(|v| v.z).unwrap_or(0.0);
205 s.last_cmd = Instant::now();
206 },
207 None,
208 )?;
209 }
210
211 let rpc_group = node.create_callback_group(CallbackGroupType::Reentrant);
212
213 {
214 let state = Arc::clone(&state);
215 node.create_service::<Reset, _>(
216 RESET_SERVICE,
217 move |_req: ResetRequest| {
218 let mut s = state.lock().expect("tank state");
219 s.snap_home();
220 ResetResponse {
221 success: true,
222 msg: String::new(),
223 }
224 },
225 Some(&rpc_group),
226 )?;
227 }
228
229 {
230 let state = Arc::clone(&state);
231 node.create_action_server::<PointNavigation, _>(
232 POINT_NAV_ACTION,
233 move |goal: PointNavigationGoal| {
234 let Some(pose) = goal.pose else {
235 abort_navigation(&state);
236 return ActionOutcome {
237 feedbacks: vec![],
238 result: PointNavigationResult {
239 success: false,
240 msg: "missing pose".into(),
241 },
242 };
243 };
244 let (ok, msg, feedbacks) =
245 navigate_waypoints(&state, &[(pose.x, pose.y, pose.theta)]);
246 ActionOutcome {
247 feedbacks: feedbacks
248 .into_iter()
249 .map(|(current_pose, progress)| PointNavigationFeedback {
250 current_pose: Some(current_pose),
251 progress,
252 })
253 .collect(),
254 result: PointNavigationResult { success: ok, msg },
255 }
256 },
257 Some(&rpc_group),
258 )?;
259 }
260
261 {
262 let state = Arc::clone(&state);
263 node.create_action_server::<MultiWaypointNavigation, _>(
264 MULTI_WAYPOINT_NAV_ACTION,
265 move |goal: MultiWaypointNavigationGoal| {
266 if goal.poses.is_empty() {
267 abort_navigation(&state);
269 return ActionOutcome {
270 feedbacks: vec![],
271 result: MultiWaypointNavigationResult {
272 success: false,
273 msg: "cancelled".into(),
274 },
275 };
276 }
277 let waypoints: Vec<(f64, f64, f64)> =
278 goal.poses.iter().map(|p| (p.x, p.y, p.theta)).collect();
279 let (ok, msg, feedbacks) = navigate_waypoints(&state, &waypoints);
280 ActionOutcome {
281 feedbacks: feedbacks
282 .into_iter()
283 .map(|(current_pose, progress)| MultiWaypointNavigationFeedback {
284 current_pose: Some(current_pose),
285 progress,
286 })
287 .collect(),
288 result: MultiWaypointNavigationResult { success: ok, msg },
289 }
290 },
291 Some(&rpc_group),
292 )?;
293 }
294
295 eprintln!(
296 "tank online — SUB {CMD_VEL_TOPIC} → PUB {POSE_TOPIC}; \
297 actions {POINT_NAV_ACTION}, {MULTI_WAYPOINT_NAV_ACTION}; \
298 service {RESET_SERVICE} (tick {}ms)",
299 TICK.as_millis()
300 );
301 log::info!(
302 "tank online — SUB {CMD_VEL_TOPIC} → PUB {POSE_TOPIC}; \
303 actions {POINT_NAV_ACTION}, {MULTI_WAYPOINT_NAV_ACTION}; \
304 service {RESET_SERVICE} (tick {}ms)",
305 TICK.as_millis()
306 );
307
308 let mut last_tick = Instant::now();
309 while !stop.load(Ordering::Relaxed) {
310 executor.spin_once(Some(Duration::from_millis(5)))?;
311
312 let now = Instant::now();
313 if now.duration_since(last_tick) < TICK {
314 continue;
315 }
316 let dt = (now - last_tick).as_secs_f64().min(0.05);
317 last_tick = now;
318
319 let pose = {
320 let mut s = state.lock().expect("tank state");
321 if !s.navigating {
322 if now.duration_since(s.last_cmd) > CMD_TIMEOUT {
323 s.linear = 0.0;
324 s.angular = 0.0;
325 }
326 s.theta += s.angular * dt;
327 s.x = (s.x + s.theta.cos() * s.linear * dt).clamp(0.0, WORLD_SIZE);
328 s.y = (s.y + s.theta.sin() * s.linear * dt).clamp(0.0, WORLD_SIZE);
329 }
330 s.pose()
331 };
332
333 if let Err(err) = pose_pub.publish(&pose) {
334 log::warn!("tank: publish {POSE_TOPIC} failed: {err}");
335 } else {
336 ready.store(true, Ordering::Relaxed);
337 }
338 }
339
340 let _ = node.shutdown();
341 log::info!("tank stopped");
342 Ok(())
343}
344
345fn abort_navigation(state: &Arc<Mutex<SimState>>) {
346 let mut s = state.lock().expect("tank state");
347 s.abort_token = s.abort_token.wrapping_add(1);
348 s.navigating = false;
349 s.linear = 0.0;
350 s.angular = 0.0;
351}
352
353fn navigate_waypoints(
355 state: &Arc<Mutex<SimState>>,
356 waypoints: &[(f64, f64, f64)],
357) -> (bool, String, Vec<(Pose2D, f32)>) {
358 let token = {
359 let mut s = state.lock().expect("tank state");
360 s.abort_token = s.abort_token.wrapping_add(1);
361 let token = s.abort_token;
362 s.navigating = true;
363 s.linear = 0.0;
364 s.angular = 0.0;
365 token
366 };
367
368 let mut feedbacks = Vec::new();
369 let mut ok = true;
370 let mut msg = String::new();
371 let n = waypoints.len().max(1) as f32;
372
373 for (i, &(gx, gy, gtheta)) in waypoints.iter().enumerate() {
374 let gx = gx.clamp(0.0, WORLD_SIZE);
375 let gy = gy.clamp(0.0, WORLD_SIZE);
376 let match_yaw = i + 1 == waypoints.len();
377 match drive_to_pose(state, token, gx, gy, gtheta, match_yaw, |pose, local| {
378 let overall = (i as f32 + local) / n;
379 feedbacks.push((pose, overall.clamp(0.0, 1.0)));
380 }) {
381 Ok(()) => {}
382 Err(reason) => {
383 ok = false;
384 msg = reason;
385 break;
386 }
387 }
388 }
389
390 {
391 let mut s = state.lock().expect("tank state");
392 if s.abort_token == token {
393 s.navigating = false;
394 s.linear = 0.0;
395 s.angular = 0.0;
396 }
397 }
398
399 if ok {
400 feedbacks.push({
401 let s = state.lock().expect("tank state");
402 (s.pose(), 1.0)
403 });
404 }
405 (ok, msg, feedbacks)
406}
407
408fn drive_to_pose(
409 state: &Arc<Mutex<SimState>>,
410 token: u64,
411 gx: f64,
412 gy: f64,
413 gtheta: f64,
414 match_yaw: bool,
415 mut on_progress: impl FnMut(Pose2D, f32),
416) -> std::result::Result<(), String> {
417 let start = {
418 let s = state.lock().expect("tank state");
419 if s.abort_token != token {
420 return Err("aborted".into());
421 }
422 (s.x, s.y)
423 };
424 let path_len = ((gx - start.0).hypot(gy - start.1)).max(1e-3);
425
426 let mut step: u32 = 0;
428 loop {
429 let (pose, phase_done, local_progress) = {
430 let mut s = state.lock().expect("tank state");
431 if s.abort_token != token {
432 return Err("aborted".into());
433 }
434 let dx = gx - s.x;
435 let dy = gy - s.y;
436 let dist = dx.hypot(dy);
437 let bearing = dy.atan2(dx);
438 let traveled = (1.0 - dist / path_len).clamp(0.0, 1.0) as f32;
439
440 if dist > POS_TOL {
441 let yaw_err = angle_diff(s.theta, bearing);
442 if yaw_err.abs() > YAW_TOL {
443 let step_ang = NAV_ANGULAR * TICK.as_secs_f64();
444 s.theta += yaw_err.signum() * step_ang.min(yaw_err.abs());
445 (s.pose(), false, traveled * 0.85)
446 } else {
447 let step_lin = NAV_LINEAR * TICK.as_secs_f64();
448 let move_by = step_lin.min(dist);
449 s.x = (s.x + s.theta.cos() * move_by).clamp(0.0, WORLD_SIZE);
450 s.y = (s.y + s.theta.sin() * move_by).clamp(0.0, WORLD_SIZE);
451 (s.pose(), false, traveled * 0.85)
452 }
453 } else if match_yaw {
454 let yaw_err = angle_diff(s.theta, gtheta);
455 if yaw_err.abs() > YAW_TOL {
456 let step_ang = NAV_ANGULAR * TICK.as_secs_f64();
457 s.theta += yaw_err.signum() * step_ang.min(yaw_err.abs());
458 (
459 s.pose(),
460 false,
461 0.85 + (1.0 - (yaw_err.abs() / PI) as f32) * 0.15,
462 )
463 } else {
464 s.x = gx;
465 s.y = gy;
466 s.theta = gtheta;
467 (s.pose(), true, 1.0)
468 }
469 } else {
470 s.x = gx;
471 s.y = gy;
472 (s.pose(), true, 1.0)
473 }
474 };
475
476 step = step.wrapping_add(1);
477 if phase_done || step % 4 == 0 {
478 on_progress(pose, local_progress);
479 }
480 if phase_done {
481 return Ok(());
482 }
483 thread::sleep(TICK);
484 }
485}
486
487fn angle_diff(from: f64, to: f64) -> f64 {
488 let mut d = (to - from) % (2.0 * PI);
489 if d > PI {
490 d -= 2.0 * PI;
491 } else if d < -PI {
492 d += 2.0 * PI;
493 }
494 d
495}
496
497#[derive(Clone, Debug)]
499pub struct TankSession {
500 pub session_id: String,
501 pub lease: Duration,
502 pub viewers: usize,
503}
504
505#[derive(Clone, Debug)]
507pub struct TankStatus {
508 pub running: bool,
509 pub viewers: usize,
510}
511
512struct ManagerInner {
513 handle: Option<TankHandle>,
514 sessions: HashMap<String, Instant>,
515 stop_after: Option<Instant>,
516}
517
518pub struct TankManager {
521 endpoints: TankEndpoints,
522 lease: Duration,
523 stop_grace: Duration,
524 inner: Mutex<ManagerInner>,
525}
526
527impl TankManager {
528 pub fn new(endpoints: TankEndpoints) -> Arc<Self> {
529 Self::with_timing(endpoints, DEFAULT_LEASE, DEFAULT_STOP_GRACE)
530 }
531
532 pub fn with_timing(
533 endpoints: TankEndpoints,
534 lease: Duration,
535 stop_grace: Duration,
536 ) -> Arc<Self> {
537 let mgr = Arc::new(Self {
538 endpoints,
539 lease,
540 stop_grace,
541 inner: Mutex::new(ManagerInner {
542 handle: None,
543 sessions: HashMap::new(),
544 stop_after: None,
545 }),
546 });
547 Self::spawn_watch(Arc::downgrade(&mgr), stop_grace);
548 mgr
549 }
550
551 pub fn lease(&self) -> Duration {
552 self.lease
553 }
554
555 pub fn acquire(&self) -> Result<TankSession> {
556 Self::stop_taken(self.sweep_now());
557
558 let mut inner = self.lock_inner();
559 if inner.handle.is_none() {
560 let handle = TankHandle::start(self.endpoints.clone())?;
561 inner.handle = Some(handle);
562 inner.stop_after = None;
563 }
564
565 let ready_handle = inner.handle.as_ref().map(|h| Arc::clone(&h.ready));
567 let session_id = Uuid::new_v4().to_string();
568 inner.sessions.insert(session_id.clone(), Instant::now());
569 let viewers = inner.sessions.len();
570 drop(inner);
571
572 if let Some(ready) = ready_handle {
573 let deadline = Instant::now() + Duration::from_secs(2);
574 while Instant::now() < deadline {
575 if ready.load(Ordering::Relaxed) {
576 break;
577 }
578 thread::sleep(Duration::from_millis(20));
579 }
580 }
581
582 Ok(TankSession {
583 session_id,
584 lease: self.lease,
585 viewers,
586 })
587 }
588
589 pub fn heartbeat(&self, session_id: &str) -> Result<TankSession> {
590 let (result, stale) = {
591 let mut inner = self.lock_inner();
592 let stale = self.sweep_take(&mut inner);
593 let result = match inner.sessions.get_mut(session_id) {
594 Some(last) => {
595 *last = Instant::now();
596 Ok(TankSession {
597 session_id: session_id.to_string(),
598 lease: self.lease,
599 viewers: inner.sessions.len(),
600 })
601 }
602 None => Err(BusError::Protocol(format!(
603 "tank session not found: {session_id}"
604 ))),
605 };
606 (result, stale)
607 };
608 Self::stop_taken(stale);
609 result
610 }
611
612 pub fn release(&self, session_id: &str) -> Result<TankStatus> {
613 let handle = {
614 let mut inner = self.lock_inner();
615 inner.sessions.remove(session_id);
616 if inner.sessions.is_empty() {
617 inner.stop_after = None;
619 inner.handle.take()
620 } else {
621 None
622 }
623 };
624 let stopped = handle.is_some();
625 Self::stop_taken(handle);
626 if stopped {
627 return Ok(TankStatus {
628 running: false,
629 viewers: 0,
630 });
631 }
632 Ok(self.status())
633 }
634
635 pub fn status(&self) -> TankStatus {
636 let handle = self.sweep_now();
637 let stopped = handle.is_some();
638 Self::stop_taken(handle);
639 if stopped {
640 return TankStatus {
641 running: false,
642 viewers: 0,
643 };
644 }
645 let inner = self.lock_inner();
646 TankStatus {
647 running: inner.handle.is_some(),
648 viewers: inner.sessions.len(),
649 }
650 }
651
652 pub fn shutdown(&self) {
654 let handle = {
655 let mut inner = self.lock_inner();
656 inner.sessions.clear();
657 inner.stop_after = None;
658 inner.handle.take()
659 };
660 Self::stop_taken(handle);
661 }
662
663 fn lock_inner(&self) -> std::sync::MutexGuard<'_, ManagerInner> {
664 self.inner.lock().unwrap_or_else(|e| e.into_inner())
665 }
666
667 fn sweep_now(&self) -> Option<TankHandle> {
668 let mut inner = self.lock_inner();
669 self.sweep_take(&mut inner)
670 }
671
672 fn stop_taken(handle: Option<TankHandle>) {
673 if let Some(handle) = handle {
674 handle.stop();
675 }
676 }
677
678 fn spawn_watch(weak: Weak<Self>, interval: Duration) {
680 let interval = interval.max(Duration::from_millis(200));
681 let _ = thread::Builder::new()
682 .name("robot-bus-tank-watch".into())
683 .spawn(move || {
684 loop {
685 thread::sleep(interval);
686 let Some(mgr) = weak.upgrade() else { break };
687 let _ = mgr.status();
688 }
689 });
690 }
691
692 fn sweep_take(&self, inner: &mut ManagerInner) -> Option<TankHandle> {
695 let now = Instant::now();
696 inner
697 .sessions
698 .retain(|_, last| now.duration_since(*last) <= self.lease);
699
700 if inner.sessions.is_empty() {
701 let should_stop = match inner.stop_after {
702 Some(deadline) => now >= deadline,
703 None => {
704 if inner.handle.is_some() {
706 inner.stop_after = Some(now + self.stop_grace);
707 }
708 false
709 }
710 };
711 if should_stop {
712 inner.stop_after = None;
713 return inner.handle.take();
714 }
715 None
716 } else {
717 inner.stop_after = None;
718 None
719 }
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn manager_tracks_sessions_without_bus() {
729 let mgr = TankManager::with_timing(
730 TankEndpoints {
731 message_xsub: "tcp://127.0.0.1:1".into(),
732 message_xpub: "tcp://127.0.0.1:1".into(),
733 service_frontend: "tcp://127.0.0.1:1".into(),
734 service_backend: "tcp://127.0.0.1:1".into(),
735 action_backend: "tcp://127.0.0.1:1".into(),
736 },
737 Duration::from_millis(200),
738 Duration::from_millis(50),
739 );
740
741 let st = mgr.status();
742 assert!(!st.running);
743 assert_eq!(st.viewers, 0);
744 }
745}