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 address a PCA9685 answers at with its six address pins low.
27pub const PAMOJA_PCA9685_DEFAULT_ADDRESS: u8 = 0x40;
28
29/// How long the oscillator takes to run once woken, in microseconds.
30pub const PAMOJA_PCA9685_OSCILLATOR_STARTUP_MICROS: u32 = 500;
31
32/// Mode register 1: restart, clock, auto-increment, sleep, and the addresses answered.
33pub const PAMOJA_PCA9685_REGISTER_MODE1: u8 = 0x00;
34/// Mode register 2: how the outputs are wired and when they change.
35pub const PAMOJA_PCA9685_REGISTER_MODE2: u8 = 0x01;
36/// The first of channel 0's four registers; channel n starts four registers on per channel.
37pub const PAMOJA_PCA9685_REGISTER_LED0_ON_L: u8 = 0x06;
38/// The first of the four ALL_LED registers, which load every channel at once.
39pub const PAMOJA_PCA9685_REGISTER_ALL_LED_ON_L: u8 = 0xFA;
40/// The prescaler that sets the PWM frequency, writable only while the part sleeps.
41pub const PAMOJA_PCA9685_REGISTER_PRE_SCALE: u8 = 0xFE;
42
43/// MODE1's RESTART bit: set when the part slept with a channel running, cleared by a written 1.
44pub const PAMOJA_PCA9685_MODE1_RESTART: u8 = 0x80;
45/// MODE1's EXTCLK bit: the prescaler divides the EXTCLK pin rather than the oscillator.
46pub const PAMOJA_PCA9685_MODE1_EXTCLK: u8 = 0x40;
47/// MODE1's auto-increment bit: the register pointer moves on after each byte.
48pub const PAMOJA_PCA9685_MODE1_AUTO_INCREMENT: u8 = 0x20;
49/// MODE1's SLEEP bit: the oscillator is off and PRE_SCALE takes a write.
50pub const PAMOJA_PCA9685_MODE1_SLEEP: u8 = 0x10;
51
52/// The power-on value of MODE1: asleep, answering the All Call address.
53pub const PAMOJA_PCA9685_MODE1_RESET: u8 = 0x11;
54/// The power-on value of MODE2: totem-pole outputs.
55pub const PAMOJA_PCA9685_MODE2_RESET: u8 = 0x04;
56/// The power-on value of PRE_SCALE: 200 Hz on the internal oscillator.
57pub const PAMOJA_PCA9685_PRE_SCALE_RESET: u8 = 0x1E;
58/// The smallest value the part loads into PRE_SCALE, about 1526 Hz.
59pub const PAMOJA_PCA9685_PRE_SCALE_MIN: u8 = 3;
60
61/// The pause a stepper driver takes after each step unless given another, in microseconds:
62/// two milliseconds, a rate common geared hobby motors follow without stalling.
63pub const PAMOJA_STEPPER_DEFAULT_STEP_MICROS: u32 = 2_000;
64
65/// How long a step and direction driver holds the direction before a step pulse, and the
66/// pulse itself, unless given another, in microseconds.
67pub const PAMOJA_STEPPER_DEFAULT_PULSE_MICROS: u32 = 10;
68
69// The header generator does not read the crates this one depends on, so these
70// carry their value rather than the name of the constant that defines it.
71const _: () = assert!(PAMOJA_PCA9685_INTERNAL_OSC_HZ == pca9685::INTERNAL_OSC_HZ);
72const _: () = assert!(PAMOJA_PCA9685_CHANNELS == pca9685::CHANNELS);
73const _: () = assert!(PAMOJA_PCA9685_COUNTS == pca9685::COUNTS);
74const _: () = assert!(PAMOJA_PCA9685_DEFAULT_ADDRESS == pca9685::DEFAULT_I2C_ADDRESS);
75const _: () =
76    assert!(PAMOJA_PCA9685_OSCILLATOR_STARTUP_MICROS == pca9685::OSCILLATOR_STARTUP_MICROS);
77const _: () = assert!(PAMOJA_PCA9685_REGISTER_MODE1 == pca9685::register::MODE1);
78const _: () = assert!(PAMOJA_PCA9685_REGISTER_MODE2 == pca9685::register::MODE2);
79const _: () = assert!(PAMOJA_PCA9685_REGISTER_LED0_ON_L == pca9685::register::LED0_ON_L);
80const _: () = assert!(PAMOJA_PCA9685_REGISTER_ALL_LED_ON_L == pca9685::register::ALL_LED_ON_L);
81const _: () = assert!(PAMOJA_PCA9685_REGISTER_PRE_SCALE == pca9685::register::PRE_SCALE);
82const _: () = assert!(PAMOJA_PCA9685_MODE1_RESTART == pca9685::mode1::RESTART);
83const _: () = assert!(PAMOJA_PCA9685_MODE1_EXTCLK == pca9685::mode1::EXTCLK);
84const _: () = assert!(PAMOJA_PCA9685_MODE1_AUTO_INCREMENT == pca9685::mode1::AUTO_INCREMENT);
85const _: () = assert!(PAMOJA_PCA9685_MODE1_SLEEP == pca9685::mode1::SLEEP);
86const _: () = assert!(PAMOJA_PCA9685_MODE1_RESET == pca9685::MODE1_RESET);
87const _: () = assert!(PAMOJA_PCA9685_MODE2_RESET == pca9685::MODE2_RESET);
88const _: () = assert!(PAMOJA_PCA9685_PRE_SCALE_RESET == pca9685::PRE_SCALE_RESET);
89const _: () = assert!(PAMOJA_PCA9685_PRE_SCALE_MIN == pca9685::PRE_SCALE_MIN);
90const _: () = assert!(PAMOJA_STEPPER_DEFAULT_STEP_MICROS == stepper::DEFAULT_STEP_MICROS);
91const _: () = assert!(PAMOJA_STEPPER_DEFAULT_PULSE_MICROS == stepper::DEFAULT_PULSE_MICROS);
92
93/// A PCA9685 channel's four register bytes.
94///
95/// The order matches the channel's four consecutive registers, so the whole
96/// struct can be written in one bus transaction.
97#[repr(C)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub struct PamojaPwm {
100    /// The low byte of the count at which the output goes high.
101    pub on_low: u8,
102    /// The high byte of that count; bit 4 is the full-on flag.
103    pub on_high: u8,
104    /// The low byte of the count at which the output goes low.
105    pub off_low: u8,
106    /// The high byte of that count; bit 4 is the full-off flag.
107    pub off_high: u8,
108}
109
110/// Which way to step a motor.
111#[repr(C)]
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum PamojaStepDirection {
114    /// Advance the sequence, turning the shaft one way.
115    Forward = 0,
116    /// Reverse the sequence, turning the shaft the other way.
117    Backward = 1,
118}
119
120/// A stepper drive pattern, trading torque, smoothness, and resolution.
121#[repr(C)]
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum PamojaStepDrive {
124    /// One coil energized at a time: four steps, least torque and least power.
125    Wave = 0,
126    /// Two adjacent coils at a time: four steps, most torque.
127    FullStep = 1,
128    /// Alternating one and two coils: eight steps, double resolution.
129    HalfStep = 2,
130}
131
132/// An opaque handle to a position in a stepper drive sequence.
133///
134/// Release it with [`pamoja_stepper_free`].
135pub struct PamojaStepper {
136    sequencer: stepper::Sequencer,
137    position: stepper::Position,
138}
139
140/// Returns the first of a PCA9685 channel's four consecutive registers.
141///
142/// # Returns
143///
144/// [`PamojaStatus::Ok`] on success, with `*out_register` set, or
145/// [`PamojaStatus::InvalidArgument`] if `channel` is 16 or above.
146///
147/// # Safety
148///
149/// `out_register` must point to a writable `uint8_t`.
150#[no_mangle]
151pub unsafe extern "C" fn pamoja_pca9685_channel_register(
152    channel: u8,
153    out_register: *mut u8,
154) -> PamojaStatus {
155    if out_register.is_null() {
156        set_last_error("out_register must not be null".to_owned());
157        return PamojaStatus::InvalidArgument;
158    }
159    if channel >= pca9685::CHANNELS {
160        set_last_error(format!("channel must be below {}", pca9685::CHANNELS));
161        return PamojaStatus::InvalidArgument;
162    }
163    *out_register = pca9685::channel_register(channel);
164    PamojaStatus::Ok
165}
166
167/// Returns the prescale value that sets a PCA9685 update rate.
168///
169/// # Returns
170///
171/// The prescale register value, clamped to what the part accepts.
172#[no_mangle]
173pub extern "C" fn pamoja_pca9685_prescale_for_frequency(update_rate_hz: u32, osc_hz: u32) -> u8 {
174    pca9685::prescale_for_frequency(update_rate_hz, osc_hz)
175}
176
177/// Returns the update rate a PCA9685 prescale value produces.
178///
179/// # Returns
180///
181/// The frequency in hertz.
182#[no_mangle]
183pub extern "C" fn pamoja_pca9685_frequency_for_prescale(prescale: u8, osc_hz: u32) -> f32 {
184    pca9685::frequency_for_prescale(prescale, osc_hz)
185}
186
187/// Builds a PWM setting from explicit on and off counts.
188///
189/// # Returns
190///
191/// The four register bytes; counts are masked to 12 bits.
192#[no_mangle]
193pub extern "C" fn pamoja_pwm_from_counts(on: u16, off: u16) -> PamojaPwm {
194    pca9685::Pwm::from_counts(on, off).into()
195}
196
197/// Builds a PWM setting with no phase delay: on at count 0, off at `off`.
198///
199/// The datasheet rules out the same count in on and off, so 0 is the full-off setting and
200/// 4096 or more the full-on one.
201///
202/// # Returns
203///
204/// The four register bytes.
205#[no_mangle]
206pub extern "C" fn pamoja_pwm_duty(off: u16) -> PamojaPwm {
207    pca9685::Pwm::duty(off).into()
208}
209
210/// Builds the setting that drives a hobby servo to a given pulse width.
211///
212/// Typical travel is about 1000 to 2000 microseconds at a 50 Hz update rate.
213///
214/// # Returns
215///
216/// The four register bytes for that pulse width.
217#[no_mangle]
218pub extern "C" fn pamoja_pwm_servo(pulse_micros: u32, update_rate_hz: u32) -> PamojaPwm {
219    pca9685::Pwm::servo(pulse_micros, update_rate_hz).into()
220}
221
222/// Reads a PCA9685 setting back from the four register bytes a channel holds.
223///
224/// # Returns
225///
226/// [`PamojaStatus::Ok`] on success, with `*out_on` and `*out_off` set to the counts
227/// the registers hold, the full-on and full-off flags included.
228///
229/// # Safety
230///
231/// `out_on` and `out_off` must each point to a writable `uint16_t`.
232#[no_mangle]
233pub unsafe extern "C" fn pamoja_pwm_counts(
234    pwm: PamojaPwm,
235    out_on: *mut u16,
236    out_off: *mut u16,
237) -> PamojaStatus {
238    if out_on.is_null() || out_off.is_null() {
239        set_last_error("out_on and out_off must not be null".to_owned());
240        return PamojaStatus::InvalidArgument;
241    }
242    let setting = pca9685::Pwm::from_bytes(&[pwm.on_low, pwm.on_high, pwm.off_low, pwm.off_high]);
243    *out_on = setting.on();
244    *out_off = setting.off();
245    PamojaStatus::Ok
246}
247
248/// The setting that holds a channel continuously high.
249///
250/// # Returns
251///
252/// The four register bytes.
253#[no_mangle]
254pub extern "C" fn pamoja_pwm_full_on() -> PamojaPwm {
255    pca9685::Pwm::full_on().into()
256}
257
258/// The setting that holds a channel continuously low, the power-on state.
259///
260/// # Returns
261///
262/// The four register bytes.
263#[no_mangle]
264pub extern "C" fn pamoja_pwm_full_off() -> PamojaPwm {
265    pca9685::Pwm::full_off().into()
266}
267
268/// Creates a stepper at the start of a drive pattern, with its position at zero.
269///
270/// # Returns
271///
272/// A new stepper the caller must release with [`pamoja_stepper_free`].
273#[no_mangle]
274pub extern "C" fn pamoja_stepper_new(drive: PamojaStepDrive) -> *mut PamojaStepper {
275    Box::into_raw(Box::new(PamojaStepper {
276        sequencer: stepper::Sequencer::new(drive.into()),
277        position: stepper::Position::new(),
278    }))
279}
280
281/// Advances a stepper one step and returns the coil pattern to apply.
282///
283/// # Returns
284///
285/// The four-bit coil pattern, or 0 if `stepper` is null. The most significant of
286/// the four bits is the first coil.
287///
288/// # Safety
289///
290/// `stepper` must be a live handle from [`pamoja_stepper_new`], or null.
291#[no_mangle]
292pub unsafe extern "C" fn pamoja_stepper_step(
293    stepper: *mut PamojaStepper,
294    direction: PamojaStepDirection,
295) -> u8 {
296    if stepper.is_null() {
297        return 0;
298    }
299    let stepper = &mut *stepper;
300    stepper.position.step(direction.into());
301    stepper.sequencer.step(direction.into())
302}
303
304/// Returns the coil pattern a stepper currently holds, without advancing it.
305///
306/// # Returns
307///
308/// The four-bit coil pattern, or 0 if `stepper` is null.
309///
310/// # Safety
311///
312/// `stepper` must be a live handle from [`pamoja_stepper_new`], or null.
313#[no_mangle]
314pub unsafe extern "C" fn pamoja_stepper_coils(stepper: *const PamojaStepper) -> u8 {
315    if stepper.is_null() {
316        return 0;
317    }
318    (*stepper).sequencer.coils()
319}
320
321/// Returns how many steps a stepper has taken, signed by direction.
322///
323/// # Returns
324///
325/// The net step count, or 0 if `stepper` is null.
326///
327/// # Safety
328///
329/// `stepper` must be a live handle from [`pamoja_stepper_new`], or null.
330#[no_mangle]
331pub unsafe extern "C" fn pamoja_stepper_steps(stepper: *const PamojaStepper) -> i32 {
332    if stepper.is_null() {
333        return 0;
334    }
335    (*stepper).position.steps()
336}
337
338/// Returns how many steps make up one electrical cycle of a drive pattern.
339///
340/// # Returns
341///
342/// `4` for wave and full-step, `8` for half-step.
343#[no_mangle]
344pub extern "C" fn pamoja_stepper_step_count(drive: PamojaStepDrive) -> usize {
345    stepper::Drive::from(drive).step_count()
346}
347
348/// Releases a stepper handle.
349///
350/// Passing null is a no-op.
351///
352/// # Safety
353///
354/// `stepper` must be a handle from [`pamoja_stepper_new`] that has not already
355/// been freed, or null. After this call it must not be used again.
356#[no_mangle]
357pub unsafe extern "C" fn pamoja_stepper_free(stepper: *mut PamojaStepper) {
358    if !stepper.is_null() {
359        drop(Box::from_raw(stepper));
360    }
361}
362
363/// Returns how many steps a rotation of `degrees` takes on a given motor.
364///
365/// # Returns
366///
367/// The step count, negative for a negative angle.
368#[no_mangle]
369pub extern "C" fn pamoja_stepper_steps_for_degrees(degrees: f32, steps_per_revolution: u32) -> i32 {
370    stepper::steps_for_degrees(degrees, steps_per_revolution)
371}
372
373impl From<pca9685::Pwm> for PamojaPwm {
374    fn from(value: pca9685::Pwm) -> Self {
375        let [on_low, on_high, off_low, off_high] = value.bytes();
376        PamojaPwm {
377            on_low,
378            on_high,
379            off_low,
380            off_high,
381        }
382    }
383}
384
385impl From<PamojaStepDirection> for stepper::Direction {
386    fn from(value: PamojaStepDirection) -> Self {
387        match value {
388            PamojaStepDirection::Forward => stepper::Direction::Forward,
389            PamojaStepDirection::Backward => stepper::Direction::Backward,
390        }
391    }
392}
393
394impl From<PamojaStepDrive> for stepper::Drive {
395    fn from(value: PamojaStepDrive) -> Self {
396        match value {
397            PamojaStepDrive::Wave => stepper::Drive::Wave,
398            PamojaStepDrive::FullStep => stepper::Drive::FullStep,
399            PamojaStepDrive::HalfStep => stepper::Drive::HalfStep,
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::ptr;
408
409    #[test]
410    fn a_half_brightness_channel_writes_its_midpoint() {
411        assert_eq!(
412            pamoja_pwm_duty(2048),
413            PamojaPwm {
414                on_low: 0x00,
415                on_high: 0x00,
416                off_low: 0x00,
417                off_high: 0x08,
418            }
419        );
420    }
421
422    #[test]
423    fn fully_off_is_its_own_encoding_not_a_zero_duty() {
424        assert_eq!(
425            pamoja_pwm_full_off(),
426            PamojaPwm {
427                on_low: 0x00,
428                on_high: 0x00,
429                off_low: 0x00,
430                off_high: 0x10,
431            },
432            "the datasheet's full-off flag"
433        );
434        assert_eq!(
435            pamoja_pwm_duty(0),
436            pamoja_pwm_full_off(),
437            "a zero duty would load the same count into on and off, which the datasheet rules out"
438        );
439        assert_eq!(pamoja_pwm_full_on().on_high, 0x10);
440    }
441
442    #[test]
443    fn a_servo_pulse_scales_against_its_update_rate() {
444        // A 1500 microsecond pulse at 50 Hz is the center of a hobby servo's travel.
445        let center = pamoja_pwm_servo(1_500, 50);
446        let counts = u16::from(center.off_low) | (u16::from(center.off_high) << 8);
447        assert_eq!(counts, 307, "1500 us * 4096 * 50 / 1e6");
448    }
449
450    #[test]
451    fn a_channel_beyond_the_part_is_refused() {
452        let mut register = 0u8;
453        // Safety: the out-pointer is writable.
454        let status = unsafe { pamoja_pca9685_channel_register(16, &mut register) };
455        assert_eq!(status, PamojaStatus::InvalidArgument);
456    }
457
458    #[test]
459    fn the_update_rate_round_trips_through_its_prescale() {
460        let prescale = pamoja_pca9685_prescale_for_frequency(50, PAMOJA_PCA9685_INTERNAL_OSC_HZ);
461        let frequency =
462            pamoja_pca9685_frequency_for_prescale(prescale, PAMOJA_PCA9685_INTERNAL_OSC_HZ);
463        assert!((frequency - 50.0).abs() < 1.0, "got {frequency} Hz");
464    }
465
466    #[test]
467    fn a_full_electrical_cycle_returns_to_its_first_pattern() {
468        let stepper = pamoja_stepper_new(PamojaStepDrive::HalfStep);
469        // Safety: the stepper is live.
470        unsafe {
471            let first = pamoja_stepper_coils(stepper);
472            for _ in 0..pamoja_stepper_step_count(PamojaStepDrive::HalfStep) {
473                pamoja_stepper_step(stepper, PamojaStepDirection::Forward);
474            }
475            assert_eq!(pamoja_stepper_coils(stepper), first);
476            assert_eq!(pamoja_stepper_steps(stepper), 8);
477            pamoja_stepper_free(stepper);
478        }
479    }
480
481    #[test]
482    fn stepping_back_and_forth_returns_the_position_to_zero() {
483        let stepper = pamoja_stepper_new(PamojaStepDrive::FullStep);
484        // Safety: the stepper is live.
485        unsafe {
486            pamoja_stepper_step(stepper, PamojaStepDirection::Forward);
487            pamoja_stepper_step(stepper, PamojaStepDirection::Backward);
488            assert_eq!(pamoja_stepper_steps(stepper), 0);
489            pamoja_stepper_free(stepper);
490        }
491    }
492
493    #[test]
494    fn calls_on_a_null_stepper_are_rejected_without_dereferencing() {
495        // Safety: passing null is explicitly handled.
496        unsafe {
497            assert_eq!(
498                pamoja_stepper_step(ptr::null_mut(), PamojaStepDirection::Forward),
499                0
500            );
501            assert_eq!(pamoja_stepper_coils(ptr::null()), 0);
502            assert_eq!(pamoja_stepper_steps(ptr::null()), 0);
503            pamoja_stepper_free(ptr::null_mut());
504        }
505    }
506
507    #[test]
508    fn a_quarter_turn_is_a_quarter_of_the_revolution() {
509        assert_eq!(pamoja_stepper_steps_for_degrees(90.0, 200), 50);
510        assert_eq!(pamoja_stepper_steps_for_degrees(-90.0, 200), -50);
511    }
512}