mecha10_behavior_runtime/actions/
set_capability_target.rs1use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
9use async_trait::async_trait;
10use mecha10_core::Receiver;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Serialize, Deserialize)]
15struct SetCapabilityTargetConfig {
16 control_topic: String,
17 request_topic: String,
18 action_set: String,
19 #[serde(default)]
20 action_clear: Option<String>,
21 payload_key: String,
22 #[serde(default)]
23 initial: Option<Value>,
24}
25
26pub struct SetCapabilityTargetNode {
27 control_topic: String,
28 request_topic: String,
29 action_set: String,
30 action_clear: Option<String>,
31 payload_key: String,
32 target: Option<Value>,
33 receiver: Option<Receiver<Value>>,
34 published_initial: bool,
35 last_status: Option<NodeStatus>,
36}
37
38impl std::fmt::Debug for SetCapabilityTargetNode {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.debug_struct("SetCapabilityTargetNode")
41 .field("control_topic", &self.control_topic)
42 .field("request_topic", &self.request_topic)
43 .field("action_set", &self.action_set)
44 .field("action_clear", &self.action_clear)
45 .field("payload_key", &self.payload_key)
46 .field("target", &self.target)
47 .field("subscribed", &self.receiver.is_some())
48 .field("published_initial", &self.published_initial)
49 .finish()
50 }
51}
52
53impl SetCapabilityTargetNode {
54 pub fn from_json(config: Value) -> anyhow::Result<Self> {
57 let cfg: SetCapabilityTargetConfig = serde_json::from_value(config)?;
58 Ok(Self {
59 control_topic: cfg.control_topic,
60 request_topic: cfg.request_topic,
61 action_set: cfg.action_set,
62 action_clear: cfg.action_clear,
63 payload_key: cfg.payload_key,
64 target: cfg.initial,
65 receiver: None,
66 published_initial: false,
67 last_status: None,
68 })
69 }
70
71 async fn publish_set(&self, ctx: &Context, value: &Value) -> anyhow::Result<()> {
72 let mut params = serde_json::Map::new();
73 params.insert(self.payload_key.clone(), value.clone());
74 let command = serde_json::json!({
75 "action": self.action_set,
76 "params": Value::Object(params),
77 });
78 ctx.publish_raw(&self.control_topic, &command).await?;
79 tracing::debug!("SetCapabilityTargetNode: published {}({:?})", self.action_set, value);
80 Ok(())
81 }
82
83 async fn publish_clear(&self, ctx: &Context) -> anyhow::Result<()> {
84 let Some(action_clear) = self.action_clear.clone() else {
85 return Ok(());
86 };
87 let command = serde_json::json!({
88 "action": action_clear,
89 "params": Value::Null,
90 });
91 ctx.publish_raw(&self.control_topic, &command).await?;
92 tracing::debug!("SetCapabilityTargetNode: published {}", action_clear);
93 Ok(())
94 }
95
96 fn poll_latest_request(payload_key: &str, receiver: &mut Receiver<Value>) -> Option<Option<Value>> {
104 let mut latest = None;
105 while let Ok(value) = receiver.try_recv() {
106 match value.as_object() {
107 Some(obj) => {
108 let payload = obj.get(payload_key).cloned().filter(|v| !v.is_null());
109 latest = Some(payload);
110 }
111 None => {
112 tracing::warn!("SetCapabilityTargetNode: dropping malformed target request: expected a JSON object")
113 }
114 }
115 }
116 latest
117 }
118}
119
120#[async_trait]
121impl BehaviorNode for SetCapabilityTargetNode {
122 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
123 if self.receiver.is_none() {
124 self.receiver = Some(ctx.subscribe_raw::<Value>(&self.request_topic).await?);
125 }
126
127 if !self.published_initial {
128 self.published_initial = true;
129 if let Some(target) = self.target.clone() {
130 self.publish_set(ctx, &target).await?;
131 }
132 }
133
134 let latest = Self::poll_latest_request(
135 &self.payload_key,
136 self.receiver.as_mut().expect("just subscribed above"),
137 );
138 if let Some(value) = latest {
139 self.target = value.clone();
140 match value {
141 Some(value) => self.publish_set(ctx, &value).await?,
142 None => self.publish_clear(ctx).await?,
143 }
144 }
145
146 let status = if self.target.is_some() || self.action_clear.is_none() {
147 NodeStatus::Running
148 } else {
149 NodeStatus::Failure
150 };
151 self.last_status = Some(status);
152 Ok(status)
153 }
154
155 fn name(&self) -> &str {
156 "set_capability_target"
157 }
158
159 fn status_snapshot(&self) -> TreeStatusSnapshot {
160 TreeStatusSnapshot {
161 name: self.name().to_string(),
162 status: self.last_status,
163 active_capability: self.active_capability().map(String::from),
164 children: Vec::new(),
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::test_helpers::create_test_context;
173
174 fn rl_config(extra: serde_json::Value) -> Value {
175 let mut base = serde_json::json!({
176 "control_topic": "/rl/control",
177 "request_topic": "/rl/target/request",
178 "action_set": "set_target_class",
179 "payload_key": "target_class"
180 });
181 merge(&mut base, extra);
182 base
183 }
184
185 fn instance_track_config(extra: serde_json::Value) -> Value {
186 let mut base = serde_json::json!({
187 "control_topic": "/instance-track/control",
188 "request_topic": "/instance-track/target/request",
189 "action_set": "set_target",
190 "action_clear": "clear",
191 "payload_key": "bbox"
192 });
193 merge(&mut base, extra);
194 base
195 }
196
197 fn merge(base: &mut Value, extra: Value) {
198 if let (Some(base), Some(extra)) = (base.as_object_mut(), extra.as_object()) {
199 for (k, v) in extra {
200 base.insert(k.clone(), v.clone());
201 }
202 }
203 }
204
205 fn sample_bbox() -> Value {
206 serde_json::json!({ "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 })
207 }
208
209 #[test]
210 fn from_json_reads_rl_shaped_config() {
211 let node = SetCapabilityTargetNode::from_json(rl_config(serde_json::json!({ "initial": "cat" }))).unwrap();
212
213 assert_eq!(node.control_topic, "/rl/control");
214 assert_eq!(node.request_topic, "/rl/target/request");
215 assert_eq!(node.action_set, "set_target_class");
216 assert_eq!(node.action_clear, None);
217 assert_eq!(node.payload_key, "target_class");
218 assert_eq!(node.target, Some(serde_json::json!("cat")));
219 }
220
221 #[test]
222 fn from_json_reads_instance_track_shaped_config() {
223 let node = SetCapabilityTargetNode::from_json(instance_track_config(serde_json::json!({
224 "initial": sample_bbox()
225 })))
226 .unwrap();
227
228 assert_eq!(node.control_topic, "/instance-track/control");
229 assert_eq!(node.request_topic, "/instance-track/target/request");
230 assert_eq!(node.action_set, "set_target");
231 assert_eq!(node.action_clear, Some("clear".to_string()));
232 assert_eq!(node.payload_key, "bbox");
233 assert_eq!(node.target, Some(sample_bbox()));
234 }
235
236 #[test]
237 fn from_json_allows_no_initial_target() {
238 let node = SetCapabilityTargetNode::from_json(rl_config(serde_json::json!({}))).unwrap();
239 assert_eq!(node.target, None);
240 }
241
242 #[test]
243 fn from_json_requires_the_core_fields() {
244 assert!(SetCapabilityTargetNode::from_json(serde_json::json!({})).is_err());
245 assert!(SetCapabilityTargetNode::from_json(serde_json::json!({
246 "control_topic": "/rl/control"
247 }))
248 .is_err());
249 }
250
251 #[test]
252 fn poll_latest_request_returns_none_when_empty() {
253 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
254 let mut receiver = Receiver::from(rx);
255 assert_eq!(
256 SetCapabilityTargetNode::poll_latest_request("target_class", &mut receiver),
257 None
258 );
259 }
260
261 #[test]
262 fn poll_latest_request_keeps_only_the_most_recent_value() {
263 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
264 let mut receiver = Receiver::from(rx);
265 tx.send(serde_json::json!({ "target_class": "dog" })).unwrap();
266 tx.send(serde_json::json!({ "target_class": "cat" })).unwrap();
267
268 assert_eq!(
269 SetCapabilityTargetNode::poll_latest_request("target_class", &mut receiver),
270 Some(Some(serde_json::json!("cat")))
271 );
272 }
273
274 #[test]
275 fn poll_latest_request_accepts_a_null_value_as_a_clear_request() {
276 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
277 let mut receiver = Receiver::from(rx);
278 tx.send(serde_json::json!({ "target_class": null })).unwrap();
279
280 assert_eq!(
281 SetCapabilityTargetNode::poll_latest_request("target_class", &mut receiver),
282 Some(None)
283 );
284 }
285
286 #[test]
287 fn poll_latest_request_accepts_an_absent_key_as_a_clear_request() {
288 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
289 let mut receiver = Receiver::from(rx);
290 tx.send(serde_json::json!({})).unwrap();
291
292 assert_eq!(
293 SetCapabilityTargetNode::poll_latest_request("bbox", &mut receiver),
294 Some(None)
295 );
296 }
297
298 #[test]
299 fn poll_latest_request_keeps_the_most_recent_bbox() {
300 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
301 let mut receiver = Receiver::from(rx);
302 tx.send(serde_json::json!({ "bbox": { "x": 0.0, "y": 0.0, "width": 0.1, "height": 0.1 } }))
303 .unwrap();
304 tx.send(serde_json::json!({ "bbox": sample_bbox() })).unwrap();
305
306 assert_eq!(
307 SetCapabilityTargetNode::poll_latest_request("bbox", &mut receiver),
308 Some(Some(sample_bbox()))
309 );
310 }
311
312 #[tokio::test]
313 #[ignore]
314 async fn tick_publishes_configured_target_once_then_stays_running() {
315 let ctx = create_test_context("test_set_capability_target_rl").await;
316
317 let mut node = SetCapabilityTargetNode::from_json(rl_config(serde_json::json!({ "initial": "cat" }))).unwrap();
318
319 let mut receiver = ctx.subscribe_raw::<Value>("/rl/control").await.unwrap();
320
321 let status = node.tick(&ctx).await.unwrap();
322 assert_eq!(status, NodeStatus::Running);
323
324 let received = tokio::time::timeout(std::time::Duration::from_millis(500), receiver.recv())
325 .await
326 .expect("timed out waiting for published command")
327 .expect("channel closed without a message");
328
329 assert_eq!(received["action"], "set_target_class");
330 assert_eq!(received["params"]["target_class"], "cat");
331
332 let status = node.tick(&ctx).await.unwrap();
333 assert_eq!(status, NodeStatus::Running);
334 let timed_out = tokio::time::timeout(std::time::Duration::from_millis(200), receiver.recv()).await;
335 assert!(timed_out.is_err(), "expected no second publish without a new request");
336 }
337
338 #[tokio::test]
339 #[ignore]
340 async fn tick_relays_a_live_request_published_on_request_topic_rl() {
341 let ctx = create_test_context("test_set_capability_target_rl_request").await;
342
343 let mut node = SetCapabilityTargetNode::from_json(rl_config(serde_json::json!({}))).unwrap();
344
345 node.tick(&ctx).await.unwrap();
346
347 let mut control_receiver = ctx.subscribe_raw::<Value>("/rl/control").await.unwrap();
348 ctx.publish_raw("/rl/target/request", &serde_json::json!({ "target_class": "dog" }))
349 .await
350 .unwrap();
351
352 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
353 node.tick(&ctx).await.unwrap();
354
355 let received = tokio::time::timeout(std::time::Duration::from_millis(500), control_receiver.recv())
356 .await
357 .expect("timed out waiting for relayed command")
358 .expect("channel closed without a message");
359
360 assert_eq!(received["action"], "set_target_class");
361 assert_eq!(received["params"]["target_class"], "dog");
362 }
363
364 #[tokio::test]
365 #[ignore]
366 async fn tick_publishes_configured_bbox_once_then_stays_running() {
367 let ctx = create_test_context("test_set_capability_target_instance_track").await;
368
369 let mut node = SetCapabilityTargetNode::from_json(instance_track_config(serde_json::json!({
370 "initial": sample_bbox()
371 })))
372 .unwrap();
373
374 let mut receiver = ctx.subscribe_raw::<Value>("/instance-track/control").await.unwrap();
375
376 let status = node.tick(&ctx).await.unwrap();
377 assert_eq!(status, NodeStatus::Running);
378
379 let received = tokio::time::timeout(std::time::Duration::from_millis(500), receiver.recv())
380 .await
381 .expect("timed out waiting for published command")
382 .expect("channel closed without a message");
383
384 assert_eq!(received["action"], "set_target");
385 assert_eq!(received["params"]["bbox"]["x"], 0.1);
386
387 let status = node.tick(&ctx).await.unwrap();
388 assert_eq!(status, NodeStatus::Running);
389 let timed_out = tokio::time::timeout(std::time::Duration::from_millis(200), receiver.recv()).await;
390 assert!(timed_out.is_err(), "expected no second publish without a new request");
391 }
392
393 #[tokio::test]
394 #[ignore]
395 async fn tick_relays_a_live_request_published_on_request_topic_instance_track() {
396 let ctx = create_test_context("test_set_capability_target_instance_track_request").await;
397
398 let mut node = SetCapabilityTargetNode::from_json(instance_track_config(serde_json::json!({}))).unwrap();
399
400 node.tick(&ctx).await.unwrap();
401
402 let mut control_receiver = ctx.subscribe_raw::<Value>("/instance-track/control").await.unwrap();
403 ctx.publish_raw(
404 "/instance-track/target/request",
405 &serde_json::json!({ "bbox": { "x": 0.5, "y": 0.5, "width": 0.1, "height": 0.1 } }),
406 )
407 .await
408 .unwrap();
409
410 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
411 node.tick(&ctx).await.unwrap();
412
413 let received = tokio::time::timeout(std::time::Duration::from_millis(500), control_receiver.recv())
414 .await
415 .expect("timed out waiting for relayed command")
416 .expect("channel closed without a message");
417
418 assert_eq!(received["action"], "set_target");
419 assert_eq!(received["params"]["bbox"]["x"], 0.5);
420 assert_eq!(
421 node.tick(&ctx).await.unwrap(),
422 NodeStatus::Running,
423 "a live target must report Running"
424 );
425 }
426
427 #[tokio::test]
428 #[ignore = "Requires Redis infrastructure"]
429 async fn tick_returns_failure_when_no_target_has_ever_been_set_and_action_clear_is_configured() {
430 let ctx = create_test_context("test_set_capability_target_idle").await;
431
432 let mut node = SetCapabilityTargetNode::from_json(instance_track_config(serde_json::json!({}))).unwrap();
433
434 let status = node.tick(&ctx).await.unwrap();
435 assert_eq!(
436 status,
437 NodeStatus::Failure,
438 "no target yet means cede control, not Success"
439 );
440 }
441
442 #[tokio::test]
443 #[ignore = "Requires Redis infrastructure"]
444 async fn tick_stays_running_when_no_target_has_ever_been_set_and_no_action_clear_is_configured() {
445 let ctx = create_test_context("test_set_capability_target_rl_idle").await;
446
447 let mut node = SetCapabilityTargetNode::from_json(rl_config(serde_json::json!({}))).unwrap();
448
449 let status = node.tick(&ctx).await.unwrap();
450 assert_eq!(
451 status,
452 NodeStatus::Running,
453 "rl-style config (no action_clear) always reports Running, matching the old SetRlTargetNode"
454 );
455 }
456
457 #[tokio::test]
458 #[ignore = "Requires Redis infrastructure"]
459 async fn tick_returns_failure_after_a_clear_request_even_with_a_configured_initial_target() {
460 let ctx = create_test_context("test_set_capability_target_cleared").await;
461
462 let mut node = SetCapabilityTargetNode::from_json(instance_track_config(serde_json::json!({
463 "initial": sample_bbox()
464 })))
465 .unwrap();
466
467 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
468
469 ctx.publish_raw("/instance-track/target/request", &serde_json::json!({ "bbox": null }))
470 .await
471 .unwrap();
472 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
473
474 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
475 }
476
477 #[tokio::test]
478 #[ignore]
479 async fn tick_relays_a_clear_request_published_on_request_topic() {
480 let ctx = create_test_context("test_set_capability_target_clear").await;
481
482 let mut node = SetCapabilityTargetNode::from_json(instance_track_config(serde_json::json!({}))).unwrap();
483
484 node.tick(&ctx).await.unwrap();
485
486 let mut control_receiver = ctx.subscribe_raw::<Value>("/instance-track/control").await.unwrap();
487 ctx.publish_raw("/instance-track/target/request", &serde_json::json!({ "bbox": null }))
488 .await
489 .unwrap();
490
491 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
492 node.tick(&ctx).await.unwrap();
493
494 let received = tokio::time::timeout(std::time::Duration::from_millis(500), control_receiver.recv())
495 .await
496 .expect("timed out waiting for relayed command")
497 .expect("channel closed without a message");
498
499 assert_eq!(received["action"], "clear");
500 }
501}