Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Custom Transport

This guide walks through adding a custom link: the byte-carrying transport surface engines send through, plus the link-level profile that wraps it. It targets 3rd parties replacing the in-memory transport with something runtime-specific, for example a TCP, BLE, or raw LoRa P2P carrier.

See Profile Implementations for the shared profile boundary. See Reference Client for the host bridge composition the custom transport plugs into. See Crate Architecture for the ownership rules the transport layer must respect.

What A Transport Owns

A transport in Jacquard has two surfaces. Engines send payloads through TransportSenderEffects during a synchronous round. The host owns ingress and supervision through TransportDriver. The bridge attaches Tick to each ingress event before delivering it to the router.

Engines must not own async I/O directly. They must not poll the transport driver. They must not attach time. A 3rd party implementing a new transport replaces the two trait surfaces and hands the result to the bridge, which keeps those ownership boundaries.

Reuse jacquard-host-support for generic mailbox, peer-directory, or claim-ownership scaffolding when the transport needs those primitives. Its ingress mailbox keeps storage bounded and exposes both generation snapshots and a wakeable changed future, so host bridges can pair it with a native blocking loop or an executor-owned embedded loop. Do not introduce a pathway-specific or engine-specific transport trait. Keep the transport transport-neutral.

Embedded transports use the same ownership split. A LoRa adapter running under Embassy owns the radio task, queues tick-free ingress into the mailbox, wakes the bridge through the portable changed-future surface, and lets the bridge stamp Tick before router ingestion. jacquard-host-support does not depend on Embassy directly; the adapter supplies that executor integration at the edge.

Implementing TransportSenderEffects

TransportSenderEffects is the synchronous send capability the router hands to each engine. An implementation takes a LinkEndpoint and a payload, dispatches it through the runtime-specific carrier, and returns an error on failure.

#![allow(unused)]
fn main() {
use jacquard_core::LinkEndpoint;
use jacquard_traits::{TransportSenderEffects, TransportError};
use std::sync::mpsc::Sender;

pub struct ChannelSender {
    outbound: Sender<(LinkEndpoint, Vec<u8>)>,
}

impl TransportSenderEffects for ChannelSender {
    fn send_transport(
        &mut self,
        endpoint: &LinkEndpoint,
        payload: &[u8],
    ) -> Result<(), TransportError> {
        self.outbound
            .send((endpoint.clone(), payload.to_vec()))
            .map_err(|_| TransportError::Unavailable)
    }
}
}

The sender runs inside a deterministic round, so the implementation must complete quickly. If the underlying carrier needs async dispatch, buffer the outbound payload here and flush it on the driver side. Do not block the round on runtime I/O.

Implementing TransportDriver

TransportDriver is the host-owned ingress surface. The bridge calls drain_transport_ingress once per round, attaches Tick to every returned event, and feeds the stamped events into the router as observations. The driver also owns lifecycle: shutdown_transport_driver releases runtime resources when the bridge is torn down.

#![allow(unused)]
fn main() {
use jacquard_traits::{TransportDriver, TransportError, TransportIngressEvent};
use std::sync::mpsc::Receiver;

pub struct ChannelDriver {
    inbound: Receiver<TransportIngressEvent>,
}

impl TransportDriver for ChannelDriver {
    fn drain_transport_ingress(
        &mut self,
    ) -> Result<Vec<TransportIngressEvent>, TransportError> {
        let mut batch = Vec::new();
        while let Ok(event) = self.inbound.try_recv() {
            batch.push(event);
        }
        Ok(batch)
    }
}
}

The driver returns a bounded batch per round, not a stream. Unbounded draining would let one round consume arbitrarily much wall-clock time. A real driver caps the drain to a per-round limit and defers remaining events to the next round.

The bridge attaches Tick to each event before delivery. The driver must not populate a tick field on its own. Handing the driver ownership of time is the primary failure mode for custom transports.

A link profile wraps the transport surfaces with the pieces the shared Link, LinkEndpoint, and LinkState vocabulary needs. jacquard-mem-link-profile is the canonical example. It provides SimulatedLinkProfile, SharedInMemoryNetwork, InMemoryTransport, InMemoryRetentionStore, and InMemoryRuntimeEffects.

TransportKind names the concrete carrier surface in the shared taxonomy. Use TransportKind::LoraP2p for raw LoRa peer-to-peer links because the variant names the link mode that Jacquard routes over, not the whole LoRa radio family. Use EndpointLocator::ScopedBytes { scope: "lora".to_owned(), bytes } for LoRa endpoint identity, with the byte payload defined by the transport-owned profile crate. Other LoRa operating modes remain custom transports until they have a shared, concrete routing surface.

#![allow(unused)]
fn main() {
use jacquard_core::{Link, LinkEndpoint};
use jacquard_traits::{RetentionStore, TransportDriver, TransportSenderEffects};

pub struct ChannelLinkProfile {
    sender: ChannelSender,
    driver: ChannelDriver,
    retention: ChannelRetentionStore,
}

impl ChannelLinkProfile {
    pub fn new(/* runtime-specific config */) -> Self {
        todo!()
    }

    pub fn sender(&mut self) -> &mut ChannelSender { &mut self.sender }
    pub fn driver(&mut self) -> &mut ChannelDriver { &mut self.driver }
    pub fn retention(&mut self) -> &mut ChannelRetentionStore { &mut self.retention }
}
}

The profile does not itself implement the effect traits. It bundles individual implementors and hands them to the bridge when the client is composed. A 3rd party composes the sender, driver, and retention store through whatever builder pattern fits the runtime. The reference client uses one per-engine sender, one shared driver, and one retention store per host.

For the retention store, either reuse InMemoryRetentionStore from jacquard-mem-link-profile or implement RetentionStore against persistent storage. Retention is independent of transport, so a custom transport paired with the in-memory retention store is a reasonable first pass.

Shaping Cast Evidence

Custom transports own physical transport facts. A LoRa profile owns spreading factor, duty cycle, gateway behavior, and acknowledgement limits. A BLE profile owns scan windows and advertising behavior. A satellite profile owns contact schedules. These facts stay in the transport-owned profile crate.

Use jacquard-cast-support when the profile needs to shape those facts into bounded unicast, multicast, or broadcast evidence, or when profile/host integration needs deterministic route-neutral delivery support for an explicit delivery objective. The helper crate sorts receiver sets deterministically, enforces explicit bounds, carries typed freshness and capacity fields, and keeps directional support separate from reverse confirmation. The helper does not implement a transport, endpoint, retry loop, or send driver.

The in-memory profile shows the intended fixture path. jacquard-cast-support derives delivery support from explicit facts and an objective. jacquard-mem-link-profile can then adapt that support into ordinary directed Link observations, while the caller owns endpoint authoring:

#![allow(unused)]
fn main() {
use jacquard_cast_support::{
    shape_unicast_delivery_support, CastDeliveryObjective, CastDeliveryPolicy, UnicastEvidence,
};
use jacquard_core::{ByteCount, LinkEndpoint, NodeId, TransportKind};
use jacquard_host_support::opaque_endpoint;
use jacquard_mem_link_profile::{CastLinkObservation, CastLinkPreset};

fn endpoint_for(node: NodeId, payload_bytes_max: ByteCount) -> LinkEndpoint {
    // Transport-owned endpoint authoring stays here.
    opaque_endpoint(TransportKind::WifiAware, vec![node.0[0]], payload_bytes_max)
}

fn links_from_unicast(evidence: &[UnicastEvidence], sender: NodeId, receiver: NodeId) -> Vec<CastLinkObservation> {
    let objective = CastDeliveryObjective::unicast(sender, receiver);
    let (support, _report) =
        shape_unicast_delivery_support(evidence.iter(), &objective, CastDeliveryPolicy::default());

    support
        .iter()
        .map(|support| CastLinkPreset::from_unicast_support(support, endpoint_for))
        .collect()
}
}

jacquard-host-support remains host plumbing. Use it for mailbox, peer-directory, endpoint convenience, and claim-ownership support. Do not put profile evidence logic there.

Composing With A Host Bridge

The composed profile plugs into the reference client’s ClientBuilder or into a custom host bridge. The default builder expects the in-memory network, so a non-default transport requires composing the router and engines directly or forking the builder. See Reference Client for the composition the reference client exposes.

The minimum composition wires three things together. First, a router that owns canonical route publication. Second, one or more engines registered on that router, each holding a queue-backed TransportSenderEffects handle. Third, a host bridge that owns the TransportDriver, drains ingress, stamps Tick, and advances the router through synchronous rounds.

For end-to-end host composition patterns, see Client Assembly and Crate Architecture.