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