mecha10-core 4.2.6

Mecha10 core runtime and foundational types
Documentation
//! Shared "dashboard request -> node resend" pattern.
//!
//! The websocket relay that forwards pub/sub topics to the dashboard never replays old
//! messages to a late-subscribing client - a dashboard tab opened (or reconnected) after a node
//! already published its one-shot/startup-only state just never sees it. Several nodes solve
//! this the same way: the dashboard publishes an empty "please resend" request on a dedicated
//! request topic, and the node republishes its latest state on its own (separate, already
//! `relay_topics`-gated) response topic in reply.
//!
//! Before this module, each node hand-rolled an identical empty request struct plus the
//! subscribe boilerplate around it (`behavior-executor`'s `ListBehaviorsRequest`,
//! `object-detector`'s `ClassesRequest`, `teleop`'s empty status-request struct). This module
//! factors out just the request side - [`OnDemandRequest`] and [`subscribe_on_demand`] - so new
//! nodes don't need to declare their own empty struct and raw subscribe call to join the
//! pattern.
//!
//! What stays node-specific, deliberately:
//! - **The response topic and type.** `relay_topics` gates outbound (node -> dashboard) topics
//!   per-topic for type safety, so there's no shared "dashboard state" channel to collapse onto.
//! - **The request topic path.** Each node still defines its own `Topic<OnDemandRequest>`
//!   constant (e.g. `/behavior/list`, `/vision/object/classes/request`,
//!   `/teleop/status/request`) and still independently opts into relaying its response topic.
//! - **What happens on receipt.** Receiving an [`OnDemandRequest`] just means "resend your
//!   current state" - the node decides what that state is and how to republish it.
//!
//! # Example
//!
//! ```rust,no_run
//! use mecha10_core::on_demand::{subscribe_on_demand, OnDemandRequest};
//! use mecha10_core::prelude::*;
//! use mecha10_core::topics::Topic;
//!
//! const CLASSES_REQUEST: Topic<OnDemandRequest> = Topic::new("/vision/object/classes/request");
//!
//! # async fn example(ctx: &Context, publish_classes: impl Fn() -> Result<()>) -> Result<()> {
//! let mut requests = subscribe_on_demand(ctx, CLASSES_REQUEST).await?;
//!
//! while requests.recv().await.is_some() {
//!     publish_classes()?;
//! }
//! # Ok(())
//! # }
//! ```

use crate::messages::Message;
use serde::{Deserialize, Serialize};

/// Empty-payload request asking a node to resend its latest on-demand state.
///
/// Carries no data - receiving one at all is the entire signal. A dashboard publishes this
/// (typically once at connect, or on every `isConnected` transition to also cover reconnects)
/// on a node's dedicated request topic to ask it to republish whatever state it would otherwise
/// have only sent once at startup or on change.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnDemandRequest;

impl Message for OnDemandRequest {}

#[cfg(feature = "messaging")]
mod subscribe {
    use super::OnDemandRequest;
    use crate::context::{Context, Receiver};
    use crate::error::Result;
    use crate::topics::Topic;

    /// Subscribe to a node's on-demand request topic.
    ///
    /// Thin wrapper around [`Context::subscribe`] typed to [`OnDemandRequest`] - it exists so
    /// nodes joining the "dashboard request -> node resend" pattern only need to define their
    /// own `Topic<OnDemandRequest>` constant, not a bespoke request struct plus a raw subscribe
    /// call. Each received request means "resend your current state"; the caller decides what
    /// that state is and republishes it on its own response topic.
    pub async fn subscribe_on_demand(
        ctx: &Context,
        topic: Topic<OnDemandRequest>,
    ) -> Result<Receiver<OnDemandRequest>> {
        ctx.subscribe(topic).await
    }
}

#[cfg(feature = "messaging")]
pub use subscribe::subscribe_on_demand;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn on_demand_request_round_trips_through_json() {
        let json = serde_json::to_string(&OnDemandRequest).expect("serializable");
        let parsed: OnDemandRequest = serde_json::from_str(&json).expect("deserializable");
        assert_eq!(parsed, OnDemandRequest);
    }

    #[test]
    fn on_demand_request_serializes_as_null() {
        // The wire shape matters here: it's what every migrated node's request topic carries,
        // so a stray field would be a breaking change for every one of them at once. A unit
        // struct serializes as JSON `null` (not `{}`) via serde_json.
        let json = serde_json::to_string(&OnDemandRequest).expect("serializable");
        assert_eq!(json, "null");
    }
}