rover_nexus_core 0.1.0

Wire-format message types (Cap'n Proto + JSON) for communication between robotic vehicles and a robot orchestration server: Rover Nexus
Documentation
// Copyright 2026 Rottinghaus Dynamics
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! SecureLink v2 wire envelope (raw QUIC+mTLS robot↔server transport).
//!
//! The robot↔server QUIC link carries several unrelated Cap'n Proto top-level
//! message types (plus a little application-level JSON) in each direction.
//! Cap'n Proto is **not** self-describing across top-level types — the bytes of
//! an `UplinkMsg` and a `NexusCommand` are indistinguishable — so each message
//! is prefixed with a single [`MessageClass`] byte telling the receiver which
//! deserializer to call and which transport lane the message belongs to.
//!
//! That one byte **is the entire envelope.** It deliberately carries nothing
//! else, because everything a richer header would add is already provided by a
//! lower layer:
//!
//! * **No length prefix.** Cap'n Proto `serialize::write_message` is already
//!   self-framing, and a QUIC datagram's boundary frames a datagram message.
//!   A length field would be a second framing layer stacked on top of those.
//! * **No version field.** Protocol version is negotiated once per connection
//!   at the transport layer, not per message.
//! * **No flags.** Reliability/ordering is determined by the transport lane the
//!   class maps to at the transport layer, not by an in-band flag.
//! * **No sequence number.** Reliable per-class streams are already delivered
//!   in order by QUIC; latest-wins datagrams (motion/teleop) need only the
//!   timestamps the payloads already carry (e.g. `Heartbeat::sent_time_ms`,
//!   `RobotAck::received_at_ms`).
//!
//! Which class rides which transport lane is the transport layer's concern, not
//! this crate's.

use serde::{Deserialize, Serialize};
use std::fmt;

/// Length of the wire envelope in bytes: a single [`MessageClass`] tag.
pub const ENVELOPE_LEN: usize = 1;

/// The message differentiator carried by the wire envelope.
///
/// Replaces the old topic-suffix strings. The value is the on-wire byte;
/// `0` is intentionally unassigned so an all-zero buffer decodes to
/// [`EnvelopeError::UnknownClass`] rather than a valid class.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum MessageClass {
    // ── robot → server ──────────────────────────────────────────────
    /// Initial app-level connection hello; payload is the robot's raw 16-byte
    /// UUID.
    Hello = 1,
    /// Realtime motion telemetry (`UplinkMsg::MotionTelemetry`). Latest-wins.
    MotionTelemetry = 2,
    /// Status telemetry (`UplinkMsg::StatusTelemetry`).
    StatusTelemetry = 3,
    /// Bulk / event uplink: every other `UplinkMsg` variant (shape, object,
    /// fault, missionRunStatus, uiCapabilities, consumableStatus,
    /// currentSettings, message, agentInfo, spatialDirectiveStatus,
    /// usageTelemetry, telemetryExtras, eventTelemetry, systemHealth). Kept
    /// separate from realtime telemetry.
    BulkUplink = 4,
    /// Command acknowledgement (`RobotAck`, carried in `AgentTelemetry`).
    Ack = 5,
    /// Liveness ping (`Heartbeat`) for RTT.
    Heartbeat = 6,

    // ── server → robot ──────────────────────────────────────────────
    /// Reliable command (`NexusCommand`); every `InternalCommand` except the
    /// latest-wins control variants. App-level ack/retry still applies.
    ReliableCommand = 7,
    /// Latest-wins control (`NexusCommand` wrapping `TeleopJoy`/`VelocityCmd`).
    Teleop = 8,
    /// Heartbeat reply (`HeartbeatAck`).
    HeartbeatAck = 9,

    // ── JSON control plane ──────────────────────────────────────────
    /// Any application-level JSON message (NOT Cap'n Proto). The specific
    /// message type is carried *inside* the payload as [`JsonMessage::kind`],
    /// so all JSON messages share this one class rather than getting a class
    /// each.
    Json = 10,
}

impl MessageClass {
    /// The on-wire byte for this class.
    #[inline]
    pub fn as_u8(self) -> u8 {
        self as u8
    }
}

impl TryFrom<u8> for MessageClass {
    type Error = EnvelopeError;

    fn try_from(byte: u8) -> Result<Self, Self::Error> {
        Ok(match byte {
            1 => MessageClass::Hello,
            2 => MessageClass::MotionTelemetry,
            3 => MessageClass::StatusTelemetry,
            4 => MessageClass::BulkUplink,
            5 => MessageClass::Ack,
            6 => MessageClass::Heartbeat,
            7 => MessageClass::ReliableCommand,
            8 => MessageClass::Teleop,
            9 => MessageClass::HeartbeatAck,
            10 => MessageClass::Json,
            other => return Err(EnvelopeError::UnknownClass(other)),
        })
    }
}

/// Errors from decoding a wire envelope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvelopeError {
    /// The frame was empty — there was no class byte to read.
    EmptyFrame,
    /// The class byte did not correspond to a known [`MessageClass`].
    UnknownClass(u8),
}

impl fmt::Display for EnvelopeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EnvelopeError::EmptyFrame => write!(f, "empty wire frame: missing message-class byte"),
            EnvelopeError::UnknownClass(b) => write!(f, "unknown message-class byte: {b}"),
        }
    }
}

impl std::error::Error for EnvelopeError {}

/// Prepend the envelope to an already-serialized payload, producing one
/// contiguous buffer suitable for a QUIC datagram or a single stream write.
///
/// Stream senders that already hold the payload in a separate buffer may prefer
/// to write `[class.as_u8()]` followed by the payload directly, avoiding this
/// copy; both produce identical bytes.
pub fn encode(class: MessageClass, payload: &[u8]) -> Vec<u8> {
    let mut buf = Vec::with_capacity(ENVELOPE_LEN + payload.len());
    buf.push(class.as_u8());
    buf.extend_from_slice(payload);
    buf
}

/// Split a received frame into its [`MessageClass`] and the borrowed payload.
///
/// Used for whole-frame transports (datagrams, or a stream message already read
/// into a buffer). Stream readers that consume the class byte incrementally can
/// instead use [`MessageClass::try_from`] on the first byte.
pub fn decode(frame: &[u8]) -> Result<(MessageClass, &[u8]), EnvelopeError> {
    let (&class_byte, payload) = frame.split_first().ok_or(EnvelopeError::EmptyFrame)?;
    let class = MessageClass::try_from(class_byte)?;
    Ok((class, payload))
}

/// The payload of a [`MessageClass::Json`] frame: an application JSON message,
/// **serialized as JSON, never Cap'n Proto**.
///
/// Cap'n Proto carries robot telemetry/commands, but a few low-rate
/// application-level control messages are plain JSON. Instead of a
/// `MessageClass` per kind, they all ride the single `Json` class and are
/// discriminated by [`kind`](JsonMessage::kind) — a string the *application*
/// owns (it defines the concrete message shapes and their `kind` tags).
/// [`data`](JsonMessage::data) is the arbitrary JSON body for that kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsonMessage {
    /// Application-defined message-type discriminator (e.g. `"app.event"`).
    pub kind: String,
    /// The JSON body for `kind`.
    pub data: serde_json::Value,
}

impl JsonMessage {
    /// Wrap a serializable body under `kind`.
    pub fn new<T: Serialize>(kind: impl Into<String>, body: &T) -> Result<Self, serde_json::Error> {
        Ok(Self {
            kind: kind.into(),
            data: serde_json::to_value(body)?,
        })
    }

    /// Serialize the whole message to JSON bytes — the `Json`-class payload.
    pub fn encode(&self) -> Result<Vec<u8>, serde_json::Error> {
        serde_json::to_vec(self)
    }

    /// Parse a `Json`-class payload back into a `JsonMessage`.
    pub fn decode(bytes: &[u8]) -> Result<Self, serde_json::Error> {
        serde_json::from_slice(bytes)
    }

    /// Deserialize the `data` body into a concrete type.
    pub fn parse<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
        serde_json::from_value(self.data.clone())
    }
}

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

    /// Every variant, used to assert exhaustive roundtrip coverage. Adding a
    /// `MessageClass` variant without updating this list fails the test below.
    const ALL_CLASSES: &[MessageClass] = &[
        MessageClass::Hello,
        MessageClass::MotionTelemetry,
        MessageClass::StatusTelemetry,
        MessageClass::BulkUplink,
        MessageClass::Ack,
        MessageClass::Heartbeat,
        MessageClass::ReliableCommand,
        MessageClass::Teleop,
        MessageClass::HeartbeatAck,
        MessageClass::Json,
    ];

    #[test]
    fn envelope_len_is_one() {
        assert_eq!(ENVELOPE_LEN, 1);
    }

    #[test]
    fn class_byte_roundtrips() {
        for &class in ALL_CLASSES {
            let byte = class.as_u8();
            assert_eq!(MessageClass::try_from(byte), Ok(class));
        }
    }

    #[test]
    fn class_list_is_exhaustive() {
        // If a variant is added, its byte will fall outside the current set and
        // this asserts we remembered to extend ALL_CLASSES. Bytes are dense
        // 1..=N, so the count must equal the max discriminant.
        let max = ALL_CLASSES.iter().map(|c| c.as_u8()).max().unwrap();
        assert_eq!(
            ALL_CLASSES.len() as u8,
            max,
            "ALL_CLASSES must list every variant 1..=max"
        );
    }

    #[test]
    fn zero_byte_is_unknown() {
        assert_eq!(
            MessageClass::try_from(0),
            Err(EnvelopeError::UnknownClass(0))
        );
    }

    #[test]
    fn unknown_byte_is_rejected() {
        assert_eq!(
            MessageClass::try_from(255),
            Err(EnvelopeError::UnknownClass(255))
        );
    }

    #[test]
    fn encode_decode_roundtrip_preserves_class_and_payload() {
        let payload = b"capnp-bytes-here";
        for &class in ALL_CLASSES {
            let frame = encode(class, payload);
            assert_eq!(frame.len(), ENVELOPE_LEN + payload.len());
            let (decoded_class, decoded_payload) = decode(&frame).expect("decode");
            assert_eq!(decoded_class, class);
            assert_eq!(decoded_payload, payload);
        }
    }

    #[test]
    fn encode_decode_roundtrip_empty_payload() {
        let frame = encode(MessageClass::Heartbeat, &[]);
        let (class, payload) = decode(&frame).expect("decode");
        assert_eq!(class, MessageClass::Heartbeat);
        assert!(payload.is_empty());
    }

    #[test]
    fn decode_empty_frame_errors() {
        assert_eq!(decode(&[]), Err(EnvelopeError::EmptyFrame));
    }

    #[test]
    fn decode_unknown_class_errors() {
        assert_eq!(
            decode(&[200, 1, 2, 3]),
            Err(EnvelopeError::UnknownClass(200))
        );
    }

    #[test]
    fn json_message_roundtrips_through_envelope() {
        #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
        struct SpawnBody {
            swarm_id: String,
            count: u32,
        }
        let body = SpawnBody {
            swarm_id: "sw-1".into(),
            count: 3,
        };
        let msg = JsonMessage::new("sim.spawn", &body).expect("wrap");
        assert_eq!(msg.kind, "sim.spawn");

        // Full path: JsonMessage -> bytes -> Json-class frame -> decode -> JsonMessage -> body.
        let frame = encode(MessageClass::Json, &msg.encode().unwrap());
        let (class, payload) = decode(&frame).unwrap();
        assert_eq!(class, MessageClass::Json);
        let back = JsonMessage::decode(payload).unwrap();
        assert_eq!(back, msg);
        assert_eq!(back.parse::<SpawnBody>().unwrap(), body);
    }

    #[test]
    fn manual_stream_framing_matches_encode() {
        // A stream sender writing [class][payload] by hand must produce the same
        // bytes as encode(), so the two framing paths are interchangeable.
        let payload = b"xyz";
        let mut manual = vec![MessageClass::Ack.as_u8()];
        manual.extend_from_slice(payload);
        assert_eq!(manual, encode(MessageClass::Ack, payload));
    }
}