Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 21 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
[workspace]
members = [".", "examples/discovery_client", "examples/client_server"]
members = [
".",
"examples/bare_metal",
"examples/client_server",
"examples/discovery_client",
]

[package]
name = "simple-someip"
Expand Down Expand Up @@ -41,6 +46,21 @@ default = ["std"]
std = ["embedded-io/std", "thiserror/std", "tracing/std"]
client = ["std", "dep:tokio", "dep:socket2", "dep:futures"]
server = ["std", "dep:tokio", "dep:socket2", "dep:futures"]
# Marks a build as intended for bare-metal / no_std consumption.
# Currently a pure marker — enables no crate code on its own. Reserved
# for future phases to gate no_std-specific helper types.
#
# **To demonstrate the bare-metal trait surface, use the
# `examples/bare_metal` workspace member directly:** `cargo run -p
# bare_metal`. That workspace member depends on `simple-someip` with
# `default-features = false, features = ["bare_metal"]`, so it
# exercises the actual bare-metal configuration.
#
# Enabling `bare_metal` on its own does NOT make the crate
# bare-metal-complete: the `client` and `server` feature paths still
# spawn per-socket I/O loops on `tokio::spawn`, and a fully tokio-free
# build additionally needs a user-provided `Spawner` impl (phase 9).
bare_metal = []

[[test]]
name = "client_server"
Expand Down
12 changes: 12 additions & 0 deletions examples/bare_metal/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "bare_metal"
version = "0.0.0"
edition = "2024"
publish = false

# The whole point of this example: depend on `simple-someip` with
# `default-features = false` (no `std` feature) and `bare_metal` on.
# This exercises the `transport` trait surface in the same minimal
# configuration a real firmware build would use.
[dependencies]
simple-someip = { path = "../..", default-features = false, features = ["bare_metal"] }
288 changes: 288 additions & 0 deletions examples/bare_metal/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
//! Host-side canary for the bare-metal trait surface.
//!
//! # What this example actually is
//!
//! A workspace-member binary that exercises `simple-someip`'s
//! `TransportSocket` / `TransportFactory` / `Timer` traits against a
//! hand-rolled mock backend. The `Cargo.toml` in this directory
//! depends on `simple-someip` with
//! `default-features = false, features = ["bare_metal"]`, so building
//! or running this example proves **that the trait surface compiles
//! under exactly the feature set a firmware consumer would use** —
//! no `std`-feature paths from `simple-someip`, no tokio, no socket2.
//! `cargo build --workspace` catches any regression that breaks this
//! surface even without running the binary.
//!
//! # How to run
//!
//! ```text
Comment on lines +10 to +18

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs claim that cargo build --workspace will catch regressions in the default-features = false, features = ["bare_metal"] dependency configuration. In a workspace build, Cargo feature unification can enable simple-someip's default/std features due to other workspace members, so this may not actually validate the minimal (no-default-features) consumer surface. Consider rewording to recommend cargo build -p bare_metal / cargo run -p bare_metal (or adding a dedicated CI step that builds/clippies -p bare_metal in isolation) as the source of truth.

Suggested change
//! or running this example proves **that the trait surface compiles
//! under exactly the feature set a firmware consumer would use** —
//! no `std`-feature paths from `simple-someip`, no tokio, no socket2.
//! `cargo build --workspace` catches any regression that breaks this
//! surface even without running the binary.
//!
//! # How to run
//!
//! ```text
//! or running this package in isolation proves **that the trait
//! surface compiles under exactly the feature set a firmware consumer
//! would use** — no `std`-feature paths from `simple-someip`, no
//! tokio, no socket2. Use `cargo build -p bare_metal` (or
//! `cargo run -p bare_metal`) as the source of truth for that check;
//! `cargo build --workspace` can unify features across workspace
//! members and may therefore mask regressions in this minimal
//! configuration.
//!
//! # How to run
//!
//! ```text
//! cargo build -p bare_metal

Copilot uses AI. Check for mistakes.
//! cargo run -p bare_metal
//! ```
//!
//! # What this is NOT
//!
//! This is **not** a runtime `no_std` demonstration. The host-side
//! mock uses `std::collections::VecDeque`, `std::sync::{Arc, Mutex}`,
//! `std::time::Instant`, and `println!` — all of which an actual
//! firmware build would replace with embedded equivalents
//! (`heapless::Deque`, `spin::Mutex`, a platform clock, `defmt!` or
//! similar). Using `std` in the *host-side driver code* is fine
//! because the purpose of this example is to verify **the
//! `simple-someip` crate itself** compiles with `default-features =
//! false` and exposes a trait surface that embedded consumers can
//! target. A true runtime-`no_std` example belongs with the phase
//! 10+ bare-metal refactor, once `Client` / `Server` can consume a
//! user-supplied transport and spawner without pulling in tokio.
//!
//! # Known gaps in the bare-metal story (independent of this example)
//!
//! `SocketManager::bind*` today still pins `F::Socket = TokioSocket`,
//! so the trait impls below — while correct — cannot be plugged into
//! the crate's `Client` / `Server` event loops yet. Two upstream
//! blockers must land first:
//!
//! 1. Relax the `F::Socket = TokioSocket` bound to
//! `F::Socket: TransportSocket` (requires stable Return-Type
//! Notation or a GAT-based parallel trait).
//! 2. Extract a `Spawner` trait so `SocketManager::bind*` can submit
//! per-socket loops to the user's executor instead of calling
//! `tokio::spawn` directly. See phase 9 in the refactor plan.
//!
//! Until (1) and (2) land, bare-metal users CAN implement the traits
//! below, but they CANNOT route their implementations through
//! `Client` / `Server`.

use core::future::Future;
use core::net::{Ipv4Addr, SocketAddrV4};
use core::task::{Context, Poll, Waker};
use core::time::Duration;

use std::collections::VecDeque;
use std::sync::{Arc, Mutex};

use simple_someip::transport::{
IoErrorKind, ReceivedDatagram, SocketOptions, Timer, TransportError, TransportFactory,
TransportSocket,
};

/// Shared in-memory pipe. A `MockFactory` built around one of these
/// hands out sockets whose `send_to` pushes to `send_queue` and whose
/// `recv_from` pops from `recv_queue`. Two factories swapped queue-
/// ends give you a bidirectional pipe.
#[derive(Default)]
struct MockPipe {
/// `(bytes, dest_addr)` pairs sent by the local socket.
send_queue: Mutex<VecDeque<(Vec<u8>, SocketAddrV4)>>,
/// `(bytes, src_addr)` pairs the local socket will read next.
recv_queue: Mutex<VecDeque<(Vec<u8>, SocketAddrV4)>>,
}

#[derive(Clone)]
struct MockFactory {
pipe: Arc<MockPipe>,
local_addr: SocketAddrV4,
}

struct MockSocket {
pipe: Arc<MockPipe>,
local_addr: SocketAddrV4,
}

impl TransportFactory for MockFactory {
type Socket = MockSocket;

fn bind(
&self,
_addr: SocketAddrV4,
_options: &SocketOptions,
) -> impl Future<Output = Result<Self::Socket, TransportError>> {
let pipe = Arc::clone(&self.pipe);
let local_addr = self.local_addr;
core::future::ready(Ok(MockSocket { pipe, local_addr }))
}
}

impl TransportSocket for MockSocket {
fn send_to(
&mut self,
buf: &[u8],
target: SocketAddrV4,
) -> impl Future<Output = Result<(), TransportError>> {
let bytes = buf.to_vec();
let pipe = Arc::clone(&self.pipe);
async move {
pipe.send_queue.lock().unwrap().push_back((bytes, target));
Ok(())
}
}

fn recv_from(
&mut self,
buf: &mut [u8],
) -> impl Future<Output = Result<ReceivedDatagram, TransportError>> {
let pipe = Arc::clone(&self.pipe);
// Copy directly into `buf` by stealing its slice lifetime out
// of the async block via a raw-pointer round-trip would be
// unsafe; instead, poll the queue on first call and fill buf
// synchronously if a datagram is ready. If the queue is empty,
// this mock returns a ready
// `Err(TransportError::Io(IoErrorKind::TimedOut))` rather than
// a pending future. In this single-threaded example we always
// send first then recv, so the timeout branch is unreachable
// here.
//
// The mock borrow-dance is awkward compared to a real UDP
// socket's recv_from; a production bare-metal impl would copy
// bytes out of its driver's receive slab directly into `buf`.
let result = {
let mut q = pipe.recv_queue.lock().unwrap();
q.pop_front()
};
match result {
Some((bytes, source)) => {
let n = bytes.len().min(buf.len());
buf[..n].copy_from_slice(&bytes[..n]);
core::future::ready(Ok(ReceivedDatagram {
bytes_received: n,
source,
truncated: n < bytes.len(),
}))
}
None => core::future::ready(Err(TransportError::Io(IoErrorKind::TimedOut))),
}
}

fn local_addr(&self) -> Result<SocketAddrV4, TransportError> {
Ok(self.local_addr)
}

fn join_multicast_v4(
&mut self,
_group: Ipv4Addr,
_iface: Ipv4Addr,
) -> Result<(), TransportError> {
// Bare-metal stacks without multicast would return
// Unsupported; our mock is happy to no-op.
Ok(())
}

fn leave_multicast_v4(
&mut self,
_group: Ipv4Addr,
_iface: Ipv4Addr,
) -> Result<(), TransportError> {
Ok(())
}
}

/// Timer that sleeps by busy-waiting on a monotonic clock.
///
/// **ANTI-PATTERN — DO NOT USE IN PRODUCTION.** Busy-waiting burns a
/// core and starves other tasks. A real bare-metal impl would park
/// the task on its hardware timer ISR (e.g. `embassy_time::Timer::after`,
/// or a custom `Future` that registers itself with the MCU's timer
/// peripheral). The `Timer` trait signature is identical; only the
/// body changes.
struct MockTimer;

impl Timer for MockTimer {
fn sleep(&self, duration: Duration) -> impl Future<Output = ()> {
// ANTI-PATTERN: busy-wait. See struct docstring.
let deadline = std::time::Instant::now() + duration;
async move {
while std::time::Instant::now() < deadline {
std::hint::spin_loop();
}
}
}
}

/// Single-step `block_on` for the demo.
///
/// **ANTI-PATTERN — DO NOT USE IN PRODUCTION.** `Waker::noop()` means
/// no wake-up signal is ever registered; a future that yields
/// `Pending` waiting on real I/O would never get polled again. The
/// loop-and-`spin_loop()` fallback here masks that by busy-spinning,
/// which is worse than useless on bare metal. Production executors
/// use proper `Waker` plumbing + a task queue driven by hardware
/// interrupts. This helper exists only to drive the demo's
/// synchronous mock futures (which resolve on the first poll).
fn block_on<F: Future>(fut: F) -> F::Output {
let waker = Waker::noop();
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => {
// ANTI-PATTERN: busy-spin. See fn docstring.
std::hint::spin_loop();
}
}
}
}

fn main() {
// Each socket owns its own pipe; the "network" is us manually
// moving bytes from A's send queue into B's recv queue below. For
// a single send/recv demo this is enough; a more realistic mock
// would wire the two queues into a cross-connected pair at bind
// time.
let pipe_a = Arc::new(MockPipe::default());
let pipe_b = Arc::new(MockPipe::default());

let factory_a = MockFactory {
pipe: Arc::clone(&pipe_a),
local_addr: SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 30500),
};
let factory_b = MockFactory {
pipe: Arc::clone(&pipe_b),
local_addr: SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 30500),
};
let options = SocketOptions::new();

let mut sock_a = block_on(factory_a.bind(factory_a.local_addr, &options)).expect("bind A");
let mut sock_b = block_on(factory_b.bind(factory_b.local_addr, &options)).expect("bind B");

let payload = b"hello bare-metal";
block_on(sock_a.send_to(payload, sock_b.local_addr().unwrap())).expect("send_to");

// DEMO-ONLY: hand-drain A's send queue into B's recv queue to
// simulate "the network carried the datagram." A real bare-metal
// integration would have its network driver (lwIP, smoltcp, a
// custom Ethernet ISR, etc.) write directly into the receiving
// socket's recv buffer — no user code touches the queues. This
// drain pattern is not a template; it exists to keep the example
// self-contained.
let sent = std::mem::take(&mut *pipe_a.send_queue.lock().unwrap());
for (bytes, _dst) in sent {
pipe_b
.recv_queue
.lock()
.unwrap()
.push_back((bytes, sock_a.local_addr().unwrap()));
}

let mut buf = [0u8; 64];
let datagram = block_on(sock_b.recv_from(&mut buf)).expect("recv_from");

assert_eq!(datagram.bytes_received, payload.len());
assert_eq!(datagram.source, sock_a.local_addr().unwrap());
assert!(!datagram.truncated);
assert_eq!(&buf[..datagram.bytes_received], payload);

// Demonstrate the Timer trait briefly.
let timer = MockTimer;
block_on(timer.sleep(Duration::from_millis(1)));

println!(
"bare-metal example: sent {} bytes from {} to {}, received cleanly.",
datagram.bytes_received,
sock_a.local_addr().unwrap(),
sock_b.local_addr().unwrap(),
);
println!(
"note: this only exercises the trait layer — see source comments \
for the Client/Server + Spawner gap (phase 9 work)."
);
}
13 changes: 7 additions & 6 deletions examples/client_server/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Client+Server hybrid example using `start_sd_announcements`.
//! Client+Server hybrid example using `Client::sd_announcements_loop`.
//!
//! Demonstrates how to run a SOME/IP application that is simultaneously:
//! - A **client** subscribing to a remote service's events
Expand All @@ -11,7 +11,7 @@
//! multicast announcements.
//!
//! The server's built-in `announcement_loop()` is NOT used — instead, the
//! client's `start_sd_announcements()` handles periodic multicast
//! client's `sd_announcements_loop()` handles periodic multicast
//! announcements. The server's `run()` loop still handles unicast SD
//! traffic (e.g. `SubscribeAck`/`SubscribeNack` responses) on its own
//! socket, which is necessary for subscription management.
Expand Down Expand Up @@ -106,8 +106,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

// ── Create the client (handles discovery, subscriptions, SD socket) ──

let (client, mut updates, run) = simple_someip::Client::<Payload>::new(interface);
let _run_handle = tokio::spawn(run);
let (client, mut updates, run_fut) = simple_someip::Client::<Payload>::new(interface);
tokio::spawn(run_fut);
client.bind_discovery().await?;
info!("Client discovery bound");

Expand All @@ -127,7 +127,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("Server bound on port {MY_SERVER_PORT}");

// NOTE: We intentionally do NOT spawn server.announcement_loop().
// The client's start_sd_announcements handles all SD traffic.
// The client's sd_announcements_loop handles all SD traffic.

let _publisher = server.publisher();

Expand All @@ -141,7 +141,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── Start combined SD announcements from the client socket ───────────

let sd_header = build_sd_header(interface);
let _announce_handle = client.start_sd_announcements(sd_header, Duration::from_secs(1));
let _announce_handle =
tokio::spawn(client.sd_announcements_loop(sd_header, Duration::from_secs(1)));
info!("Started combined Find+Offer SD announcements (1s interval)");
Comment thread
JustinKovacich marked this conversation as resolved.

// ── Main event loop ─────────────────────────────────────────────────
Expand Down
Loading