Skip to main content

pamoja_ffi/
actuators.rs

1//! The C ABI for the actuator drivers.
2//!
3//! These functions wrap [`pamoja_actuators`] for callers that reach the SDK
4//! through the flat C boundary: the command-encode half of a PCA9685 PWM
5//! controller and of a stepper motor, turning a desired output into the bytes and
6//! coil patterns a driver applies.
7//!
8//! A PWM setting is four register bytes, so it crosses by value as a
9//! `#[repr(C)]` struct the caller writes straight to the channel's registers. A
10//! stepper sequencer and position both carry state across calls, so they cross as
11//! handles.
12
13use pamoja_actuators::{pca9685, stepper};
14
15use crate::{set_last_error, PamojaStatus};
16
17/// The PCA9685's internal oscillator frequency, in hertz.
18pub const PAMOJA_PCA9685_INTERNAL_OSC_HZ: u32 = 25_000_000;
19
20/// How many PWM channels a PCA9685 drives.
21pub const PAMOJA_PCA9685_CHANNELS: u8 = 16;
22
23/// How many counts a PCA9685 period is divided into.
24pub const PAMOJA_PCA9685_COUNTS: u16 = 4096;
25
26// The header generator does not read the crates this one depends on, so these
27// carry their value rather than the name of the constant that defines it.
28const _: () = assert!(PAMOJA_PCA9685_INTERNAL_OSC_HZ == pca9685::INTERNAL_OSC_HZ);
29const _: () = assert!(PAMOJA_PCA9685_CHANNELS == pca9685::CHANNELS);
30const _: () = assert!(PAMOJA_PCA9685_COUNTS == pca9685::COUNTS);
31
32/// A PCA9685 channel's four register bytes.
33///
34/// The order matches the channel's four consecutive registers, so the whole
35/// struct can be written in one bus transaction.
36#[repr(C)]
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct PamojaPwm {
39    /// The low byte of the count at which the output goes high.
40    pub on_low: u8,
41    /// The high byte of that count; bit 4 is the full-on flag.
42    pub on_high: u8,
43    /// The low byte of the count at which the output goes low.
44    pub off_low: u8,
45    /// The high byte of that count; bit 4 is the full-off flag.
46    pub off_high: u8,
47}
48
49/// Which way to step a motor.
50#[repr(C)]
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum PamojaStepDirection {
53    /// Advance the sequence, turning the shaft one way.
54    Forward = 0,
55    /// Reverse the sequence, turning the shaft the other way.
56    Backward = 1,
57}
58
59/// A stepper drive pattern, trading torque, smoothness, and resolution.
60#[repr(C)]
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum PamojaStepDrive {
63    /// One coil energised at a time: four steps, least torque and least power.
64    Wave = 0,
65    /// Two adjacent coils at a time: four steps, most torque.
66    FullStep = 1,
67    /// Alternating one and two coils: eight steps, double resolution.
68    HalfStep = 2,
69}
70
71/// An opaque handle to a position in a stepper drive sequence.
72///
73/// Release it with [`pamoja_stepper_free`].
74pub struct PamojaStepper {
75    sequencer: stepper::Sequencer,
76    position: stepper::Position,
77}
78
79/// Returns the first of a PCA9685 channel's four consecutive registers.
80///
81/// # Returns
82///
83/// [`PamojaStatus::Ok`] on success, with `*out_register` set, or
84/// [`PamojaStatus::InvalidArgument`] if `channel` is 16 or above.
85///
86/// # Safety
87///
88/// `out_register` must point to a writable `uint8_t`.
89#[no_mangle]
90pub unsafe extern "C" fn pamoja_pca9685_channel_register(
91    channel: u8,
92    out_register: *mut u8,
93) -> PamojaStatus {
94    if out_register.is_null() {
95        set_last_error("out_register must not be null".to_owned());
96        return PamojaStatus::InvalidArgument;
97    }
98    if channel >= pca9685::CHANNELS {
99        set_last_error(format!("channel must be below {}", pca9685::CHANNELS));
100        return PamojaStatus::InvalidArgument;
101    }
102    *out_register = pca9685::channel_register(channel);
103    PamojaStatus::Ok
104}
105
106/// Returns the prescale value that sets a PCA9685 update rate.
107///
108/// # Returns
109///
110/// The prescale register value, clamped to what the part accepts.
111#[no_mangle]
112pub extern "C" fn pamoja_pca9685_prescale_for_frequency(update_rate_hz: u32, osc_hz: u32) -> u8 {
113    pca9685::prescale_for_frequency(update_rate_hz, osc_hz)
114}
115
116/// Returns the update rate a PCA9685 prescale value produces.
117///
118/// # Returns
119///
120/// The frequency in hertz.
121#[no_mangle]
122pub extern "C" fn pamoja_pca9685_frequency_for_prescale(prescale: u8, osc_hz: u32) -> f32 {
123    pca9685::frequency_for_prescale(prescale, osc_hz)
124}
125
126/// Builds a PWM setting from explicit on and off counts.
127///
128/// # Returns
129///
130/// The four register bytes; counts are masked to 12 bits.
131#[no_mangle]
132pub extern "C" fn pamoja_pwm_from_counts(on: u16, off: u16) -> PamojaPwm {
133    pca9685::Pwm::from_counts(on, off).into()
134}
135
136/// Builds a PWM setting with no phase delay: on at count 0, off at `off`.
137///
138/// # Returns
139///
140/// The four register bytes.
141#[no_mangle]
142pub extern "C" fn pamoja_pwm_duty(off: u16) -> PamojaPwm {
143    pca9685::Pwm::duty(off).into()
144}
145
146/// Builds the setting that drives a hobby servo to a given pulse width.
147///
148/// Typical travel is about 1000 to 2000 microseconds at a 50 Hz update rate.
149///
150/// # Returns
151///
152/// The four register bytes for that pulse width.
153#[no_mangle]
154pub extern "C" fn pamoja_pwm_servo(pulse_micros: u32, update_rate_hz: u32) -> PamojaPwm {
155    pca9685::Pwm::servo(pulse_micros, update_rate_hz).into()
156}
157
158/// Reads a PCA9685 setting back from the four register bytes a channel holds.
159///
160/// # Returns
161///
162/// [`PamojaStatus::Ok`] on success, with `*out_on` and `*out_off` set to the counts
163/// the registers hold, the full-on and full-off flags included.
164///
165/// # Safety
166///
167/// `out_on` and `out_off` must each point to a writable `uint16_t`.
168#[no_mangle]
169pub unsafe extern "C" fn pamoja_pwm_counts(
170    pwm: PamojaPwm,
171    out_on: *mut u16,
172    out_off: *mut u16,
173) -> PamojaStatus {
174    if out_on.is_null() || out_off.is_null() {
175        set_last_error("out_on and out_off must not be null".to_owned());
176        return PamojaStatus::InvalidArgument;
177    }
178    let setting = pca9685::Pwm::from_bytes(&[pwm.on_low, pwm.on_high, pwm.off_low, pwm.off_high]);
179    *out_on = setting.on();
180    *out_off = setting.off();
181    PamojaStatus::Ok
182}
183
184/// The setting that holds a channel continuously high.
185///
186/// # Returns
187///
188/// The four register bytes.
189#[no_mangle]
190pub extern "C" fn pamoja_pwm_full_on() -> PamojaPwm {
191    pca9685::Pwm::full_on().into()
192}
193
194/// The setting that holds a channel continuously low, the power-on state.
195///
196/// # Returns
197///
198/// The four register bytes.
199#[no_mangle]
200pub extern "C" fn pamoja_pwm_full_off() -> PamojaPwm {
201    pca9685::Pwm::full_off().into()
202}
203
204/// Creates a stepper at the start of a drive pattern, with its position at zero.
205///
206/// # Returns
207///
208/// A new stepper the caller must release with [`pamoja_stepper_free`].
209#[no_mangle]
210pub extern "C" fn pamoja_stepper_new(drive: PamojaStepDrive) -> *mut PamojaStepper {
211    Box::into_raw(Box::new(PamojaStepper {
212        sequencer: stepper::Sequencer::new(drive.into()),
213        position: stepper::Position::new(),
214    }))
215}
216
217/// Advances a stepper one step and returns the coil pattern to apply.
218///
219/// # Returns
220///
221/// The four-bit coil pattern, or 0 if `stepper` is null. The most significant of
222/// the four bits is the first coil.
223///
224/// # Safety
225///
226/// `stepper` must be a live handle from [`pamoja_stepper_new`], or null.
227#[no_mangle]
228pub unsafe extern "C" fn pamoja_stepper_step(
229    stepper: *mut PamojaStepper,
230    direction: PamojaStepDirection,
231) -> u8 {
232    if stepper.is_null() {
233        return 0;
234    }
235    let stepper = &mut *stepper;
236    stepper.position.step(direction.into());
237    stepper.sequencer.step(direction.into())
238}
239
240/// Returns the coil pattern a stepper currently holds, without advancing it.
241///
242/// # Returns
243///
244/// The four-bit coil pattern, or 0 if `stepper` is null.
245///
246/// # Safety
247///
248/// `stepper` must be a live handle from [`pamoja_stepper_new`], or null.
249#[no_mangle]
250pub unsafe extern "C" fn pamoja_stepper_coils(stepper: *const PamojaStepper) -> u8 {
251    if stepper.is_null() {
252        return 0;
253    }
254    (*stepper).sequencer.coils()
255}
256
257/// Returns how many steps a stepper has taken, signed by direction.
258///
259/// # Returns
260///
261/// The net step count, or 0 if `stepper` is null.
262///
263/// # Safety
264///
265/// `stepper` must be a live handle from [`pamoja_stepper_new`], or null.
266#[no_mangle]
267pub unsafe extern "C" fn pamoja_stepper_steps(stepper: *const PamojaStepper) -> i32 {
268    if stepper.is_null() {
269        return 0;
270    }
271    (*stepper).position.steps()
272}
273
274/// Returns how many steps make up one electrical cycle of a drive pattern.
275///
276/// # Returns
277///
278/// `4` for wave and full-step, `8` for half-step.
279#[no_mangle]
280pub extern "C" fn pamoja_stepper_step_count(drive: PamojaStepDrive) -> usize {
281    stepper::Drive::from(drive).step_count()
282}
283
284/// Releases a stepper handle.
285///
286/// Passing null is a no-op.
287///
288/// # Safety
289///
290/// `stepper` must be a handle from [`pamoja_stepper_new`] that has not already
291/// been freed, or null. After this call it must not be used again.
292#[no_mangle]
293pub unsafe extern "C" fn pamoja_stepper_free(stepper: *mut PamojaStepper) {
294    if !stepper.is_null() {
295        drop(Box::from_raw(stepper));
296    }
297}
298
299/// Returns how many steps a rotation of `degrees` takes on a given motor.
300///
301/// # Returns
302///
303/// The step count, negative for a negative angle.
304#[no_mangle]
305pub extern "C" fn pamoja_stepper_steps_for_degrees(degrees: f32, steps_per_revolution: u32) -> i32 {
306    stepper::steps_for_degrees(degrees, steps_per_revolution)
307}
308
309impl From<pca9685::Pwm> for PamojaPwm {
310    fn from(value: pca9685::Pwm) -> Self {
311        let [on_low, on_high, off_low, off_high] = value.bytes();
312        PamojaPwm {
313            on_low,
314            on_high,
315            off_low,
316            off_high,
317        }
318    }
319}
320
321impl From<PamojaStepDirection> for stepper::Direction {
322    fn from(value: PamojaStepDirection) -> Self {
323        match value {
324            PamojaStepDirection::Forward => stepper::Direction::Forward,
325            PamojaStepDirection::Backward => stepper::Direction::Backward,
326        }
327    }
328}
329
330impl From<PamojaStepDrive> for stepper::Drive {
331    fn from(value: PamojaStepDrive) -> Self {
332        match value {
333            PamojaStepDrive::Wave => stepper::Drive::Wave,
334            PamojaStepDrive::FullStep => stepper::Drive::FullStep,
335            PamojaStepDrive::HalfStep => stepper::Drive::HalfStep,
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::ptr;
344
345    #[test]
346    fn a_half_brightness_channel_writes_its_midpoint() {
347        assert_eq!(
348            pamoja_pwm_duty(2048),
349            PamojaPwm {
350                on_low: 0x00,
351                on_high: 0x00,
352                off_low: 0x00,
353                off_high: 0x08,
354            }
355        );
356    }
357
358    #[test]
359    fn fully_off_is_its_own_encoding_not_a_zero_duty() {
360        assert_eq!(
361            pamoja_pwm_full_off(),
362            PamojaPwm {
363                on_low: 0x00,
364                on_high: 0x00,
365                off_low: 0x00,
366                off_high: 0x10,
367            },
368            "a zero duty still glitches high for one count; the flag does not"
369        );
370        assert_eq!(pamoja_pwm_full_on().on_high, 0x10);
371    }
372
373    #[test]
374    fn a_servo_pulse_scales_against_its_update_rate() {
375        // A 1500 microsecond pulse at 50 Hz is the centre of a hobby servo's travel.
376        let centre = pamoja_pwm_servo(1_500, 50);
377        let counts = u16::from(centre.off_low) | (u16::from(centre.off_high) << 8);
378        assert_eq!(counts, 307, "1500 us * 4096 * 50 / 1e6");
379    }
380
381    #[test]
382    fn a_channel_beyond_the_part_is_refused() {
383        let mut register = 0u8;
384        // Safety: the out-pointer is writable.
385        let status = unsafe { pamoja_pca9685_channel_register(16, &mut register) };
386        assert_eq!(status, PamojaStatus::InvalidArgument);
387    }
388
389    #[test]
390    fn the_update_rate_round_trips_through_its_prescale() {
391        let prescale = pamoja_pca9685_prescale_for_frequency(50, PAMOJA_PCA9685_INTERNAL_OSC_HZ);
392        let frequency =
393            pamoja_pca9685_frequency_for_prescale(prescale, PAMOJA_PCA9685_INTERNAL_OSC_HZ);
394        assert!((frequency - 50.0).abs() < 1.0, "got {frequency} Hz");
395    }
396
397    #[test]
398    fn a_full_electrical_cycle_returns_to_its_first_pattern() {
399        let stepper = pamoja_stepper_new(PamojaStepDrive::HalfStep);
400        // Safety: the stepper is live.
401        unsafe {
402            let first = pamoja_stepper_coils(stepper);
403            for _ in 0..pamoja_stepper_step_count(PamojaStepDrive::HalfStep) {
404                pamoja_stepper_step(stepper, PamojaStepDirection::Forward);
405            }
406            assert_eq!(pamoja_stepper_coils(stepper), first);
407            assert_eq!(pamoja_stepper_steps(stepper), 8);
408            pamoja_stepper_free(stepper);
409        }
410    }
411
412    #[test]
413    fn stepping_back_and_forth_returns_the_position_to_zero() {
414        let stepper = pamoja_stepper_new(PamojaStepDrive::FullStep);
415        // Safety: the stepper is live.
416        unsafe {
417            pamoja_stepper_step(stepper, PamojaStepDirection::Forward);
418            pamoja_stepper_step(stepper, PamojaStepDirection::Backward);
419            assert_eq!(pamoja_stepper_steps(stepper), 0);
420            pamoja_stepper_free(stepper);
421        }
422    }
423
424    #[test]
425    fn calls_on_a_null_stepper_are_rejected_without_dereferencing() {
426        // Safety: passing null is explicitly handled.
427        unsafe {
428            assert_eq!(
429                pamoja_stepper_step(ptr::null_mut(), PamojaStepDirection::Forward),
430                0
431            );
432            assert_eq!(pamoja_stepper_coils(ptr::null()), 0);
433            assert_eq!(pamoja_stepper_steps(ptr::null()), 0);
434            pamoja_stepper_free(ptr::null_mut());
435        }
436    }
437
438    #[test]
439    fn a_quarter_turn_is_a_quarter_of_the_revolution() {
440        assert_eq!(pamoja_stepper_steps_for_degrees(90.0, 200), 50);
441        assert_eq!(pamoja_stepper_steps_for_degrees(-90.0, 200), -50);
442    }
443}