Skip to main content

pamoja_ffi/
sim.rs

1//! The C ABI for simulated devices.
2//!
3//! These functions wrap [`pamoja_sim`] so a caller can drive a whole node with
4//! no hardware attached. That is worth more from a binding than from Rust:
5//! someone writing against the SDK in Python or C# can put a sensor, an
6//! actuator, and a lossy link into a unit test and find out what their code does
7//! when a reading drifts or a packet vanishes, without owning the device.
8//!
9//! The degraded link lives with the transports, in
10//! [`pamoja_transport_degraded`](crate::transport::pamoja_transport_degraded),
11//! because it wraps a transport rather than standing alone.
12
13use std::ptr;
14
15use pamoja_core::{Actuator, Sensor};
16use pamoja_kit::{Pose, Twist};
17use pamoja_sim::{RecordingActuator, Replay, SimRobot, SimSensor};
18
19use crate::{runtime, set_last_error, PamojaStatus};
20
21/// Where a robot is and which way it faces.
22#[repr(C)]
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct PamojaPose {
25    /// Position along the world x axis, in metres.
26    pub x: f32,
27    /// Position along the world y axis, in metres.
28    pub y: f32,
29    /// Heading from the world x axis, in radians, positive counter-clockwise.
30    pub theta: f32,
31}
32
33/// How fast a robot is asked to move.
34#[repr(C)]
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct PamojaTwist {
37    /// Forward speed along the x axis.
38    pub vx: f32,
39    /// Leftward speed along the y axis; zero for drives that cannot strafe.
40    pub vy: f32,
41    /// Yaw rate about the z axis, positive counter-clockwise.
42    pub omega: f32,
43}
44
45/// An opaque handle to a sensor that invents plausible readings.
46pub struct PamojaSimSensor {
47    inner: SimSensor,
48}
49
50/// An opaque handle to a sensor that replays a recorded series.
51pub struct PamojaReplay {
52    inner: Replay,
53}
54
55/// An opaque handle to an actuator that records what it was told to do.
56pub struct PamojaRecordingActuator {
57    inner: RecordingActuator<f32>,
58}
59
60/// An opaque handle to a robot that moves only in arithmetic.
61pub struct PamojaSimRobot {
62    inner: SimRobot,
63}
64
65/// Creates a sensor that reads around a baseline.
66///
67/// # Arguments
68///
69/// * `baseline` - the value it reads before drift and noise.
70/// * `drift_per_read` - how much the baseline moves each read, or 0 for none.
71/// * `noise` - the amplitude of the wobble around it, or 0 for none.
72/// * `seed` - the seed for that wobble, so a run repeats.
73///
74/// # Returns
75///
76/// A handle the caller must release with [`pamoja_sim_sensor_free`].
77#[no_mangle]
78pub extern "C" fn pamoja_sim_sensor_new(
79    baseline: f32,
80    drift_per_read: f32,
81    noise: f32,
82    seed: u32,
83) -> *mut PamojaSimSensor {
84    let mut sensor = SimSensor::new(baseline);
85    if drift_per_read != 0.0 {
86        sensor = sensor.with_drift(drift_per_read);
87    }
88    if noise != 0.0 {
89        sensor = sensor.with_noise(noise);
90    }
91    if seed != 0 {
92        sensor = sensor.with_seed(seed);
93    }
94    Box::into_raw(Box::new(PamojaSimSensor { inner: sensor }))
95}
96
97/// Takes the next reading.
98///
99/// # Arguments
100///
101/// * `sensor` - the sensor.
102/// * `out_reading` - receives the reading.
103///
104/// # Returns
105///
106/// [`PamojaStatus::Ok`] on success.
107///
108/// # Safety
109///
110/// `sensor` must be a live handle from [`pamoja_sim_sensor_new`] and
111/// `out_reading` must be writable.
112#[no_mangle]
113pub unsafe extern "C" fn pamoja_sim_sensor_read(
114    sensor: *mut PamojaSimSensor,
115    out_reading: *mut f32,
116) -> PamojaStatus {
117    if sensor.is_null() || out_reading.is_null() {
118        set_last_error("sensor and out_reading must not be null".to_owned());
119        return PamojaStatus::InvalidArgument;
120    }
121    match runtime().block_on((*sensor).inner.read()) {
122        Ok(reading) => {
123            *out_reading = reading;
124            PamojaStatus::Ok
125        }
126        Err(error) => fail(error),
127    }
128}
129
130/// Releases a simulated sensor handle.
131///
132/// Passing null is a no-op.
133///
134/// # Safety
135///
136/// `sensor` must be a handle from [`pamoja_sim_sensor_new`] that has not already
137/// been freed, or null. After this call it must not be used again.
138#[no_mangle]
139pub unsafe extern "C" fn pamoja_sim_sensor_free(sensor: *mut PamojaSimSensor) {
140    if !sensor.is_null() {
141        drop(Box::from_raw(sensor));
142    }
143}
144
145/// Creates a sensor that reads back a recorded series.
146///
147/// This is how a caller replays a real capture, so a test asks what the code
148/// does with readings that actually happened rather than ones it invented.
149///
150/// # Arguments
151///
152/// * `readings` - the series to read back.
153/// * `count` - how many readings `readings` holds.
154/// * `repeating` - `true` to start again at the beginning once exhausted,
155///   `false` to keep returning the last one.
156///
157/// # Returns
158///
159/// A handle the caller must release with [`pamoja_replay_free`].
160///
161/// # Safety
162///
163/// `readings` must point to at least `count` readable floats, or be null when
164/// `count` is 0.
165#[no_mangle]
166pub unsafe extern "C" fn pamoja_replay_new(
167    readings: *const f32,
168    count: usize,
169    repeating: bool,
170) -> *mut PamojaReplay {
171    if count != 0 && readings.is_null() {
172        set_last_error("readings must not be null when count is non-zero".to_owned());
173        return ptr::null_mut();
174    }
175    let values = if count == 0 {
176        Vec::new()
177    } else {
178        std::slice::from_raw_parts(readings, count).to_vec()
179    };
180    let inner = if repeating {
181        Replay::repeating(values)
182    } else {
183        Replay::new(values)
184    };
185    Box::into_raw(Box::new(PamojaReplay { inner }))
186}
187
188/// Takes the next reading from a replay.
189///
190/// # Arguments
191///
192/// * `replay` - the replay.
193/// * `out_reading` - receives the reading.
194///
195/// # Returns
196///
197/// [`PamojaStatus::Ok`] on success.
198///
199/// # Safety
200///
201/// `replay` must be a live handle from [`pamoja_replay_new`] and `out_reading`
202/// must be writable.
203#[no_mangle]
204pub unsafe extern "C" fn pamoja_replay_read(
205    replay: *mut PamojaReplay,
206    out_reading: *mut f32,
207) -> PamojaStatus {
208    if replay.is_null() || out_reading.is_null() {
209        set_last_error("replay and out_reading must not be null".to_owned());
210        return PamojaStatus::InvalidArgument;
211    }
212    match runtime().block_on((*replay).inner.read()) {
213        Ok(reading) => {
214            *out_reading = reading;
215            PamojaStatus::Ok
216        }
217        Err(error) => fail(error),
218    }
219}
220
221/// Releases a replay handle.
222///
223/// Passing null is a no-op.
224///
225/// # Safety
226///
227/// `replay` must be a handle from [`pamoja_replay_new`] that has not already
228/// been freed, or null. After this call it must not be used again.
229#[no_mangle]
230pub unsafe extern "C" fn pamoja_replay_free(replay: *mut PamojaReplay) {
231    if !replay.is_null() {
232        drop(Box::from_raw(replay));
233    }
234}
235
236/// Creates an actuator that records every command instead of acting on one.
237///
238/// # Returns
239///
240/// A handle the caller must release with [`pamoja_recording_actuator_free`].
241#[no_mangle]
242pub extern "C" fn pamoja_recording_actuator_new() -> *mut PamojaRecordingActuator {
243    Box::into_raw(Box::new(PamojaRecordingActuator {
244        inner: RecordingActuator::new(),
245    }))
246}
247
248/// Applies a command, which the actuator records rather than acts on.
249///
250/// # Arguments
251///
252/// * `actuator` - the actuator.
253/// * `command` - the value commanded.
254///
255/// # Returns
256///
257/// [`PamojaStatus::Ok`] on success.
258///
259/// # Safety
260///
261/// `actuator` must be a live handle from [`pamoja_recording_actuator_new`].
262#[no_mangle]
263pub unsafe extern "C" fn pamoja_recording_actuator_apply(
264    actuator: *mut PamojaRecordingActuator,
265    command: f32,
266) -> PamojaStatus {
267    if actuator.is_null() {
268        set_last_error("actuator must not be null".to_owned());
269        return PamojaStatus::InvalidArgument;
270    }
271    match runtime().block_on((*actuator).inner.apply(command)) {
272        Ok(()) => PamojaStatus::Ok,
273        Err(error) => fail(error),
274    }
275}
276
277/// Reports how many commands an actuator has been given.
278///
279/// # Arguments
280///
281/// * `actuator` - the actuator.
282///
283/// # Returns
284///
285/// The number of commands, or 0 if `actuator` is null.
286///
287/// # Safety
288///
289/// `actuator` must be a live handle from [`pamoja_recording_actuator_new`], or
290/// null.
291#[no_mangle]
292pub unsafe extern "C" fn pamoja_recording_actuator_len(
293    actuator: *const PamojaRecordingActuator,
294) -> usize {
295    if actuator.is_null() {
296        return 0;
297    }
298    (*actuator).inner.log().len()
299}
300
301/// Copies out the commands an actuator recorded, oldest first.
302///
303/// # Arguments
304///
305/// * `actuator` - the actuator.
306/// * `out_commands` - receives up to `capacity` commands.
307/// * `capacity` - how many floats `out_commands` can hold.
308///
309/// # Returns
310///
311/// How many commands were written, which is the smaller of `capacity` and the
312/// count from [`pamoja_recording_actuator_len`].
313///
314/// # Safety
315///
316/// `actuator` must be a live handle, and `out_commands` must point to at least
317/// `capacity` writable floats or be null when `capacity` is 0.
318#[no_mangle]
319pub unsafe extern "C" fn pamoja_recording_actuator_commands(
320    actuator: *const PamojaRecordingActuator,
321    out_commands: *mut f32,
322    capacity: usize,
323) -> usize {
324    if actuator.is_null() || capacity == 0 || out_commands.is_null() {
325        return 0;
326    }
327    let commands = (*actuator).inner.log().commands();
328    let written = commands.len().min(capacity);
329    ptr::copy_nonoverlapping(commands.as_ptr(), out_commands, written);
330    written
331}
332
333/// Releases a recording actuator handle.
334///
335/// Passing null is a no-op.
336///
337/// # Safety
338///
339/// `actuator` must be a handle from [`pamoja_recording_actuator_new`] that has
340/// not already been freed, or null. After this call it must not be used again.
341#[no_mangle]
342pub unsafe extern "C" fn pamoja_recording_actuator_free(actuator: *mut PamojaRecordingActuator) {
343    if !actuator.is_null() {
344        drop(Box::from_raw(actuator));
345    }
346}
347
348/// Creates a robot that moves only in arithmetic.
349///
350/// # Arguments
351///
352/// * `start` - the pose it begins at.
353/// * `dt` - the seconds each command advances it; its magnitude is used.
354///
355/// # Returns
356///
357/// A handle the caller must release with [`pamoja_sim_robot_free`].
358#[no_mangle]
359pub extern "C" fn pamoja_sim_robot_new(start: PamojaPose, dt: f32) -> *mut PamojaSimRobot {
360    Box::into_raw(Box::new(PamojaSimRobot {
361        inner: SimRobot::starting_at(Pose::new(start.x, start.y, start.theta), dt),
362    }))
363}
364
365/// Drives the robot for one time step.
366///
367/// # Arguments
368///
369/// * `robot` - the robot.
370/// * `command` - the speeds to hold for one step.
371///
372/// # Returns
373///
374/// [`PamojaStatus::Ok`] on success.
375///
376/// # Safety
377///
378/// `robot` must be a live handle from [`pamoja_sim_robot_new`].
379#[no_mangle]
380pub unsafe extern "C" fn pamoja_sim_robot_apply(
381    robot: *mut PamojaSimRobot,
382    command: PamojaTwist,
383) -> PamojaStatus {
384    if robot.is_null() {
385        set_last_error("robot must not be null".to_owned());
386        return PamojaStatus::InvalidArgument;
387    }
388    let twist = Twist::new(command.vx, command.vy, command.omega);
389    match runtime().block_on((*robot).inner.apply(twist)) {
390        Ok(()) => PamojaStatus::Ok,
391        Err(error) => fail(error),
392    }
393}
394
395/// Reads where the robot has got to.
396///
397/// # Arguments
398///
399/// * `robot` - the robot.
400///
401/// # Returns
402///
403/// The pose reached so far, or an all-zero pose if `robot` is null.
404///
405/// # Safety
406///
407/// `robot` must be a live handle from [`pamoja_sim_robot_new`], or null.
408#[no_mangle]
409pub unsafe extern "C" fn pamoja_sim_robot_pose(robot: *const PamojaSimRobot) -> PamojaPose {
410    if robot.is_null() {
411        return PamojaPose {
412            x: 0.0,
413            y: 0.0,
414            theta: 0.0,
415        };
416    }
417    let pose = (*robot).inner.pose();
418    PamojaPose {
419        x: pose.x,
420        y: pose.y,
421        theta: pose.theta,
422    }
423}
424
425/// Releases a simulated robot handle.
426///
427/// Passing null is a no-op.
428///
429/// # Safety
430///
431/// `robot` must be a handle from [`pamoja_sim_robot_new`] that has not already
432/// been freed, or null. After this call it must not be used again.
433#[no_mangle]
434pub unsafe extern "C" fn pamoja_sim_robot_free(robot: *mut PamojaSimRobot) {
435    if !robot.is_null() {
436        drop(Box::from_raw(robot));
437    }
438}
439
440/// Records an error and maps it onto a status.
441fn fail(error: pamoja_core::Error) -> PamojaStatus {
442    let status = PamojaStatus::from_error(&error);
443    set_last_error(error.to_string());
444    status
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn a_seeded_sensor_repeats_its_run() {
453        unsafe {
454            let first = pamoja_sim_sensor_new(20.0, 0.5, 1.0, 42);
455            let second = pamoja_sim_sensor_new(20.0, 0.5, 1.0, 42);
456
457            for _ in 0..5 {
458                let (mut a, mut b) = (0.0, 0.0);
459                assert_eq!(pamoja_sim_sensor_read(first, &mut a), PamojaStatus::Ok);
460                assert_eq!(pamoja_sim_sensor_read(second, &mut b), PamojaStatus::Ok);
461                assert_eq!(a, b, "the same seed gives the same readings");
462            }
463
464            pamoja_sim_sensor_free(first);
465            pamoja_sim_sensor_free(second);
466        }
467    }
468
469    #[test]
470    fn a_replay_reads_back_what_was_recorded() {
471        unsafe {
472            let readings = [21.0f32, 21.5, 22.0];
473            let replay = pamoja_replay_new(readings.as_ptr(), readings.len(), true);
474
475            // Twice around, because it repeats.
476            for _ in 0..2 {
477                for want in readings {
478                    let mut got = 0.0;
479                    assert_eq!(pamoja_replay_read(replay, &mut got), PamojaStatus::Ok);
480                    assert_eq!(got, want);
481                }
482            }
483
484            pamoja_replay_free(replay);
485        }
486    }
487
488    #[test]
489    fn an_actuator_records_what_it_was_told() {
490        unsafe {
491            let actuator = pamoja_recording_actuator_new();
492            for command in [0.0f32, 0.5, 1.0] {
493                assert_eq!(
494                    pamoja_recording_actuator_apply(actuator, command),
495                    PamojaStatus::Ok
496                );
497            }
498            assert_eq!(pamoja_recording_actuator_len(actuator), 3);
499
500            let mut commands = [0.0f32; 3];
501            assert_eq!(
502                pamoja_recording_actuator_commands(actuator, commands.as_mut_ptr(), 3),
503                3
504            );
505            assert_eq!(commands, [0.0, 0.5, 1.0]);
506
507            // A short buffer takes what fits rather than overrunning.
508            let mut room_for_one = [0.0f32; 1];
509            assert_eq!(
510                pamoja_recording_actuator_commands(actuator, room_for_one.as_mut_ptr(), 1),
511                1
512            );
513            assert_eq!(room_for_one, [0.0]);
514
515            pamoja_recording_actuator_free(actuator);
516        }
517    }
518
519    #[test]
520    fn a_robot_driven_forward_ends_up_ahead() {
521        unsafe {
522            let start = PamojaPose {
523                x: 0.0,
524                y: 0.0,
525                theta: 0.0,
526            };
527            let robot = pamoja_sim_robot_new(start, 1.0);
528
529            let forward = PamojaTwist {
530                vx: 1.0,
531                vy: 0.0,
532                omega: 0.0,
533            };
534            assert_eq!(pamoja_sim_robot_apply(robot, forward), PamojaStatus::Ok);
535
536            let pose = pamoja_sim_robot_pose(robot);
537            assert!(
538                (pose.x - 1.0).abs() < 1e-5,
539                "one second at one metre a second"
540            );
541            assert!(pose.y.abs() < 1e-5);
542
543            pamoja_sim_robot_free(robot);
544        }
545    }
546
547    #[test]
548    fn a_null_handle_is_refused_rather_than_dereferenced() {
549        unsafe {
550            assert_eq!(
551                pamoja_sim_sensor_read(ptr::null_mut(), ptr::null_mut()),
552                PamojaStatus::InvalidArgument
553            );
554            assert_eq!(pamoja_recording_actuator_len(ptr::null()), 0);
555            assert_eq!(pamoja_sim_robot_pose(ptr::null()).x, 0.0);
556            pamoja_sim_sensor_free(ptr::null_mut());
557            pamoja_replay_free(ptr::null_mut());
558            pamoja_recording_actuator_free(ptr::null_mut());
559            pamoja_sim_robot_free(ptr::null_mut());
560        }
561    }
562}