Skip to main content

pamoja_ffi/
gpio.rs

1//! The C ABI for on-board bus addressing and pin logic.
2//!
3//! These functions wrap [`pamoja_gpio`] for callers that reach the SDK through
4//! the flat C boundary: I2C addressing per NXP UM10204, the four SPI clock modes,
5//! the pin model that maps a logical "asserted" onto a physical level, and a line
6//! opened on a Linux board.
7//!
8//! The addressing and the pin model allocate nothing and hold no state. An I2C
9//! address is two scalars and crosses by value as [`PamojaI2cAddress`]; the rest
10//! are enumerations and small pure functions over them. A [`PamojaGpioLine`] is the
11//! one handle: a line on a Linux board's GPIO chip, opened with
12//! [`pamoja_gpio_line_open_output`] or [`pamoja_gpio_line_open_input`] and let go with
13//! [`pamoja_gpio_line_free`]. On any other platform opening one returns
14//! [`PamojaStatus::Unsupported`] with a message saying why.
15
16use std::os::raw::c_char;
17use std::panic::{catch_unwind, AssertUnwindSafe};
18
19use pamoja_gpio::i2c::{Address, Direction};
20use pamoja_gpio::linux::{self, Line, OpenError};
21use pamoja_gpio::pin::{Edge, Level, Polarity};
22use pamoja_gpio::spi::Mode;
23use pamoja_gpio::GpioError;
24
25use crate::{read_str, set_last_error, PamojaStatus};
26
27/// The largest I2C address frame, in bytes: the two a 10-bit address needs.
28pub const PAMOJA_I2C_FRAME_MAX: usize = 2;
29
30/// The lowest 7-bit address the I2C specification keeps for itself.
31pub const PAMOJA_I2C_RESERVED_FROM: u8 = 0x78;
32
33/// The first 7-bit address above the reserved block at the bottom of the range.
34pub const PAMOJA_I2C_RESERVED_BELOW: u8 = 0x08;
35
36// The header generator does not read the crates this one depends on, so these
37// carry their value rather than the name of the constant that defines it.
38const _: () = assert!(PAMOJA_I2C_RESERVED_FROM == pamoja_gpio::i2c::RESERVED_FROM);
39const _: () = assert!(PAMOJA_I2C_RESERVED_BELOW == pamoja_gpio::i2c::RESERVED_BELOW);
40
41/// A validated I2C device address.
42///
43/// Build one with [`pamoja_i2c_address_seven_bit`] or
44/// [`pamoja_i2c_address_ten_bit`], which reject a value outside the width's
45/// range. Both fields are scalars, so this crosses the boundary by value.
46#[repr(C)]
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct PamojaI2cAddress {
49    /// The address itself, without the read/write bit.
50    pub value: u16,
51    /// `1` for a 10-bit address, `0` for a 7-bit one.
52    pub ten_bit: u8,
53}
54
55/// Which direction an I2C transfer runs, as the read/write bit encodes it.
56#[repr(C)]
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum PamojaI2cDirection {
59    /// The controller writes to the device. Read/write bit `0`.
60    Write = 0,
61    /// The controller reads from the device. Read/write bit `1`.
62    Read = 1,
63}
64
65/// The physical voltage level on a pin.
66#[repr(C)]
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum PamojaPinLevel {
69    /// A low level, near ground.
70    Low = 0,
71    /// A high level, near the supply voltage.
72    High = 1,
73}
74
75/// The signal transition that triggers a pin interrupt.
76#[repr(C)]
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum PamojaPinEdge {
79    /// A low-to-high transition.
80    Rising = 0,
81    /// A high-to-low transition.
82    Falling = 1,
83    /// Either transition.
84    Both = 2,
85}
86
87/// Whether a signal is asserted by a high or a low physical level.
88#[repr(C)]
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum PamojaPinPolarity {
91    /// A high level means asserted.
92    ActiveHigh = 0,
93    /// A low level means asserted, the wiring of most buttons and relay boards.
94    ActiveLow = 1,
95}
96
97/// Validates a 7-bit I2C address.
98///
99/// The whole `0x00..=0x7F` range is accepted, reserved addresses included, since
100/// those are still legal on the wire. Test for them with
101/// [`pamoja_i2c_address_is_reserved`].
102///
103/// # Returns
104///
105/// [`PamojaStatus::Ok`] on success, with `*out_address` set, or
106/// [`PamojaStatus::InvalidArgument`] if `address` is above `0x7F`.
107///
108/// # Safety
109///
110/// `out_address` must point to a writable `PamojaI2cAddress`.
111#[no_mangle]
112pub unsafe extern "C" fn pamoja_i2c_address_seven_bit(
113    address: u8,
114    out_address: *mut PamojaI2cAddress,
115) -> PamojaStatus {
116    emit(Address::seven_bit(address), out_address)
117}
118
119/// Validates a 10-bit I2C address.
120///
121/// # Returns
122///
123/// [`PamojaStatus::Ok`] on success, with `*out_address` set, or
124/// [`PamojaStatus::InvalidArgument`] if `address` is above `0x3FF`.
125///
126/// # Safety
127///
128/// `out_address` must point to a writable `PamojaI2cAddress`.
129#[no_mangle]
130pub unsafe extern "C" fn pamoja_i2c_address_ten_bit(
131    address: u16,
132    out_address: *mut PamojaI2cAddress,
133) -> PamojaStatus {
134    emit(Address::ten_bit(address), out_address)
135}
136
137/// Returns how many bytes an address frame occupies.
138///
139/// # Returns
140///
141/// `1` for a 7-bit address, `2` for a 10-bit one.
142#[no_mangle]
143pub extern "C" fn pamoja_i2c_address_frame_len(address: PamojaI2cAddress) -> usize {
144    borrow(address).map_or(0, Address::frame_len)
145}
146
147/// Writes the address bytes a controller puts on the bus for a transfer.
148///
149/// # Returns
150///
151/// [`PamojaStatus::Ok`] on success, with `*out_len` set to how many bytes of
152/// `out_frame` were written, or [`PamojaStatus::InvalidArgument`] if the address
153/// is not one this SDK produced or `out_frame_cap` is smaller than
154/// [`pamoja_i2c_address_frame_len`].
155///
156/// # Safety
157///
158/// `out_frame` must point to at least `out_frame_cap` writable bytes, and
159/// `out_len` must point to a writable `size_t`.
160#[no_mangle]
161pub unsafe extern "C" fn pamoja_i2c_address_frame(
162    address: PamojaI2cAddress,
163    direction: PamojaI2cDirection,
164    out_frame: *mut u8,
165    out_frame_cap: usize,
166    out_len: *mut usize,
167) -> PamojaStatus {
168    if out_frame.is_null() || out_len.is_null() {
169        set_last_error("out_frame and out_len must not be null".to_owned());
170        return PamojaStatus::InvalidArgument;
171    }
172    let Some(address) = borrow(address) else {
173        set_last_error("address is not a valid I2C address".to_owned());
174        return PamojaStatus::InvalidArgument;
175    };
176    let out = std::slice::from_raw_parts_mut(out_frame, out_frame_cap);
177    match address.write_frame(direction.into(), out) {
178        Ok(written) => {
179            *out_len = written;
180            PamojaStatus::Ok
181        }
182        Err(error) => failed(error),
183    }
184}
185
186/// Reports whether a 7-bit address falls in a range the I2C specification reserves.
187///
188/// # Returns
189///
190/// `true` for a 7-bit address in `0x00..=0x07` or `0x78..=0x7F`, which leaves
191/// `0x08..=0x77` for ordinary devices. A 10-bit address is never reserved in this
192/// sense.
193#[no_mangle]
194pub extern "C" fn pamoja_i2c_address_is_reserved(address: PamojaI2cAddress) -> bool {
195    borrow(address).is_some_and(Address::is_reserved)
196}
197
198/// Reports whether an address is the general call address `0x00`.
199///
200/// # Returns
201///
202/// `true` for the broadcast every device on the bus listens to.
203#[no_mangle]
204pub extern "C" fn pamoja_i2c_address_is_general_call(address: PamojaI2cAddress) -> bool {
205    borrow(address).is_some_and(Address::is_general_call)
206}
207
208/// Returns the `(CPOL, CPHA)` pair an SPI mode number names.
209///
210/// # Returns
211///
212/// [`PamojaStatus::Ok`] on success, with `*out_cpol` set to whether the clock
213/// idles high and `*out_cpha` to whether data is sampled on the trailing edge, or
214/// [`PamojaStatus::InvalidArgument`] if `mode` is above 3.
215///
216/// # Safety
217///
218/// `out_cpol` and `out_cpha` must each point to a writable `bool`.
219#[no_mangle]
220pub unsafe extern "C" fn pamoja_spi_mode_cpol_cpha(
221    mode: u8,
222    out_cpol: *mut bool,
223    out_cpha: *mut bool,
224) -> PamojaStatus {
225    if out_cpol.is_null() || out_cpha.is_null() {
226        set_last_error("out_cpol and out_cpha must not be null".to_owned());
227        return PamojaStatus::InvalidArgument;
228    }
229    let Some(mode) = Mode::from_number(mode) else {
230        set_last_error("SPI mode must be 0, 1, 2, or 3".to_owned());
231        return PamojaStatus::InvalidArgument;
232    };
233    let (cpol, cpha) = mode.cpol_cpha();
234    *out_cpol = cpol;
235    *out_cpha = cpha;
236    PamojaStatus::Ok
237}
238
239/// Returns the SPI mode number a `(CPOL, CPHA)` pair names.
240///
241/// # Returns
242///
243/// The mode number `0..=3`. Every pair names a mode, so this never fails.
244#[no_mangle]
245pub extern "C" fn pamoja_spi_mode_from_cpol_cpha(cpol: bool, cpha: bool) -> u8 {
246    Mode::from_cpol_cpha(cpol, cpha).number()
247}
248
249/// Returns the level a boolean names.
250///
251/// # Returns
252///
253/// [`PamojaPinLevel::High`] for `true`, [`PamojaPinLevel::Low`] for `false`.
254#[no_mangle]
255pub extern "C" fn pamoja_pin_level_from_bool(high: bool) -> PamojaPinLevel {
256    Level::from_bool(high).into()
257}
258
259/// Returns the opposite level.
260///
261/// # Returns
262///
263/// The inverted level.
264#[no_mangle]
265pub extern "C" fn pamoja_pin_level_inverted(level: PamojaPinLevel) -> PamojaPinLevel {
266    Level::from(level).inverted().into()
267}
268
269/// Reports whether a change from one level to another fires an interrupt trigger.
270///
271/// # Returns
272///
273/// `true` if the transition matches `edge`; `false` for the other direction or
274/// for no change at all.
275#[no_mangle]
276pub extern "C" fn pamoja_pin_edge_triggered_by(
277    edge: PamojaPinEdge,
278    from: PamojaPinLevel,
279    to: PamojaPinLevel,
280) -> bool {
281    Edge::from(edge).triggered_by(from.into(), to.into())
282}
283
284/// Returns the physical level that represents a logical state under a polarity.
285///
286/// # Returns
287///
288/// The level to drive, which for active-low wiring is the inverse of `asserted`.
289#[no_mangle]
290pub extern "C" fn pamoja_pin_polarity_level(
291    polarity: PamojaPinPolarity,
292    asserted: bool,
293) -> PamojaPinLevel {
294    Polarity::from(polarity).level(asserted).into()
295}
296
297/// Reports whether a physical level means the signal is asserted.
298///
299/// # Returns
300///
301/// `true` if `level` asserts the signal under `polarity`.
302#[no_mangle]
303pub extern "C" fn pamoja_pin_polarity_is_asserted(
304    polarity: PamojaPinPolarity,
305    level: PamojaPinLevel,
306) -> bool {
307    Polarity::from(polarity).is_asserted(level.into())
308}
309
310/// A GPIO line opened on a Linux board. Opaque; let it go with [`pamoja_gpio_line_free`],
311/// which hands the line back to the kernel.
312pub struct PamojaGpioLine {
313    line: Line,
314}
315
316/// Opens a line on a Linux board as an output, driving `initial` from the moment it is taken.
317///
318/// # Arguments
319///
320/// * `chip` - the GPIO chip's device file, such as `/dev/gpiochip0`.
321/// * `line` - the line's number on that chip, the GPIO or BCM number on a Raspberry Pi.
322/// * `initial` - the level to drive as soon as the line is taken; an active-low relay is
323///   opened high so it stays off.
324/// * `out_line` - receives the line, or null when opening fails.
325///
326/// # Returns
327///
328/// [`PamojaStatus::Ok`] with the line in `out_line`; [`PamojaStatus::Unsupported`] on any
329/// platform but Linux; [`PamojaStatus::InvalidArgument`] for a null or non-UTF-8 argument;
330/// or [`PamojaStatus::Io`] when the chip or the line cannot be opened. The last error
331/// message names the chip and the line.
332///
333/// # Safety
334///
335/// `chip` must be a null-terminated string or null, and `out_line` a writable pointer or
336/// null.
337#[no_mangle]
338pub unsafe extern "C" fn pamoja_gpio_line_open_output(
339    chip: *const c_char,
340    line: u32,
341    initial: PamojaPinLevel,
342    out_line: *mut *mut PamojaGpioLine,
343) -> PamojaStatus {
344    open_line(chip, out_line, |chip| {
345        linux::output(chip, line, initial.into())
346    })
347}
348
349/// Opens a line on a Linux board as an input.
350///
351/// # Arguments
352///
353/// * `chip` - the GPIO chip's device file, such as `/dev/gpiochip0`.
354/// * `line` - the line's number on that chip.
355/// * `out_line` - receives the line, or null when opening fails.
356///
357/// # Returns
358///
359/// As [`pamoja_gpio_line_open_output`].
360///
361/// # Safety
362///
363/// `chip` must be a null-terminated string or null, and `out_line` a writable pointer or
364/// null.
365#[no_mangle]
366pub unsafe extern "C" fn pamoja_gpio_line_open_input(
367    chip: *const c_char,
368    line: u32,
369    out_line: *mut *mut PamojaGpioLine,
370) -> PamojaStatus {
371    open_line(chip, out_line, |chip| linux::input(chip, line))
372}
373
374/// Drives an output line to a level.
375///
376/// # Arguments
377///
378/// * `line` - the line.
379/// * `level` - the level to drive.
380///
381/// # Returns
382///
383/// [`PamojaStatus::Ok`]; [`PamojaStatus::InvalidArgument`] for a null line; or
384/// [`PamojaStatus::Io`] when the kernel refuses the write, which is what an input line
385/// answers.
386///
387/// # Safety
388///
389/// `line` must be a live handle from one of the open functions, or null.
390#[no_mangle]
391pub unsafe extern "C" fn pamoja_gpio_line_drive(
392    line: *mut PamojaGpioLine,
393    level: PamojaPinLevel,
394) -> PamojaStatus {
395    match on_line(line, |line| line.drive(level.into())) {
396        Ok(()) => PamojaStatus::Ok,
397        Err(status) => status,
398    }
399}
400
401/// Reads the level on a line now.
402///
403/// # Arguments
404///
405/// * `line` - the line.
406/// * `out_level` - receives the level.
407///
408/// # Returns
409///
410/// [`PamojaStatus::Ok`] with the level in `out_level`; [`PamojaStatus::InvalidArgument`] for
411/// a null argument; or [`PamojaStatus::Io`] when the kernel refuses the read.
412///
413/// # Safety
414///
415/// `line` must be a live handle from one of the open functions, or null, and `out_level` a
416/// writable pointer or null.
417#[no_mangle]
418pub unsafe extern "C" fn pamoja_gpio_line_read(
419    line: *mut PamojaGpioLine,
420    out_level: *mut PamojaPinLevel,
421) -> PamojaStatus {
422    if out_level.is_null() {
423        set_last_error("out_level must not be null".to_owned());
424        return PamojaStatus::InvalidArgument;
425    }
426    match on_line(line, Line::read) {
427        Ok(level) => {
428            *out_level = level.into();
429            PamojaStatus::Ok
430        }
431        Err(status) => status,
432    }
433}
434
435/// Lets a line go, handing it back to the kernel. A null pointer is ignored.
436///
437/// # Safety
438///
439/// `line` must be a handle from one of the open functions that has not been freed, or null.
440#[no_mangle]
441pub unsafe extern "C" fn pamoja_gpio_line_free(line: *mut PamojaGpioLine) {
442    if !line.is_null() {
443        drop(Box::from_raw(line));
444    }
445}
446
447/// Opens a line into an out pointer, turning a refusal or a panic into a status.
448///
449/// # Safety
450///
451/// `chip` must be a null-terminated string or null, and `out_line` a writable pointer or
452/// null.
453unsafe fn open_line(
454    chip: *const c_char,
455    out_line: *mut *mut PamojaGpioLine,
456    opening: impl FnOnce(&str) -> Result<Line, OpenError>,
457) -> PamojaStatus {
458    if out_line.is_null() {
459        set_last_error("out_line must not be null".to_owned());
460        return PamojaStatus::InvalidArgument;
461    }
462    *out_line = std::ptr::null_mut();
463    let Some(chip) = read_str(chip, "chip") else {
464        return PamojaStatus::InvalidArgument;
465    };
466    match catch_unwind(AssertUnwindSafe(|| opening(chip))) {
467        Ok(Ok(line)) => {
468            *out_line = Box::into_raw(Box::new(PamojaGpioLine { line }));
469            PamojaStatus::Ok
470        }
471        Ok(Err(error)) => {
472            set_last_error(error.to_string());
473            match error {
474                OpenError::Unsupported => PamojaStatus::Unsupported,
475                OpenError::Gpio { .. } => PamojaStatus::Io,
476            }
477        }
478        Err(_) => panicked(),
479    }
480}
481
482/// Records a caught panic and reports it as [`PamojaStatus::Panic`].
483fn panicked() -> PamojaStatus {
484    set_last_error("panic at the FFI boundary".to_owned());
485    PamojaStatus::Panic
486}
487
488/// Runs a call on a live line, turning a refusal or a panic into a status.
489///
490/// # Safety
491///
492/// `line` must be a live handle from one of the open functions, or null.
493unsafe fn on_line<T>(
494    line: *mut PamojaGpioLine,
495    call: impl FnOnce(&mut Line) -> Result<T, linux::LineError>,
496) -> Result<T, PamojaStatus> {
497    if line.is_null() {
498        set_last_error("line must not be null".to_owned());
499        return Err(PamojaStatus::InvalidArgument);
500    }
501    let line = &mut (*line).line;
502    match catch_unwind(AssertUnwindSafe(|| call(line))) {
503        Ok(Ok(value)) => Ok(value),
504        Ok(Err(error)) => {
505            set_last_error(error.to_string());
506            Err(PamojaStatus::Io)
507        }
508        Err(_) => Err(panicked()),
509    }
510}
511
512impl From<PamojaI2cDirection> for Direction {
513    fn from(value: PamojaI2cDirection) -> Self {
514        match value {
515            PamojaI2cDirection::Write => Direction::Write,
516            PamojaI2cDirection::Read => Direction::Read,
517        }
518    }
519}
520
521impl From<PamojaPinLevel> for Level {
522    fn from(value: PamojaPinLevel) -> Self {
523        match value {
524            PamojaPinLevel::Low => Level::Low,
525            PamojaPinLevel::High => Level::High,
526        }
527    }
528}
529
530impl From<Level> for PamojaPinLevel {
531    fn from(value: Level) -> Self {
532        match value {
533            Level::Low => PamojaPinLevel::Low,
534            Level::High => PamojaPinLevel::High,
535        }
536    }
537}
538
539impl From<PamojaPinEdge> for Edge {
540    fn from(value: PamojaPinEdge) -> Self {
541        match value {
542            PamojaPinEdge::Rising => Edge::Rising,
543            PamojaPinEdge::Falling => Edge::Falling,
544            PamojaPinEdge::Both => Edge::Both,
545        }
546    }
547}
548
549impl From<PamojaPinPolarity> for Polarity {
550    fn from(value: PamojaPinPolarity) -> Self {
551        match value {
552            PamojaPinPolarity::ActiveHigh => Polarity::ActiveHigh,
553            PamojaPinPolarity::ActiveLow => Polarity::ActiveLow,
554        }
555    }
556}
557
558/// Rebuilds the validated address a [`PamojaI2cAddress`] describes.
559///
560/// The value crossed the boundary as plain scalars, so a caller could have
561/// changed it since; this puts it back through the same validation that produced
562/// it rather than trusting the fields.
563fn borrow(address: PamojaI2cAddress) -> Option<Address> {
564    if address.ten_bit == 0 {
565        u8::try_from(address.value)
566            .ok()
567            .and_then(|value| Address::seven_bit(value).ok())
568    } else {
569        Address::ten_bit(address.value).ok()
570    }
571}
572
573/// Writes a validated address to the caller's slot, or reports why it is invalid.
574///
575/// # Safety
576///
577/// `out_address` must point to a writable `PamojaI2cAddress`.
578unsafe fn emit(
579    address: Result<Address, GpioError>,
580    out_address: *mut PamojaI2cAddress,
581) -> PamojaStatus {
582    if out_address.is_null() {
583        set_last_error("out_address must not be null".to_owned());
584        return PamojaStatus::InvalidArgument;
585    }
586    match address {
587        Ok(address) => {
588            *out_address = PamojaI2cAddress {
589                value: address.value(),
590                ten_bit: u8::from(address.is_ten_bit()),
591            };
592            PamojaStatus::Ok
593        }
594        Err(error) => failed(error),
595    }
596}
597
598/// Records an addressing error and maps it onto its status.
599fn failed(error: GpioError) -> PamojaStatus {
600    set_last_error(error.to_string());
601    PamojaStatus::InvalidArgument
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    /// Builds a 7-bit address, which the tests use as their ordinary case.
609    fn seven_bit(value: u8) -> PamojaI2cAddress {
610        let mut address = PamojaI2cAddress {
611            value: 0,
612            ten_bit: 0,
613        };
614        // Safety: the out-pointer is writable.
615        let status = unsafe { pamoja_i2c_address_seven_bit(value, &mut address) };
616        assert_eq!(status, PamojaStatus::Ok);
617        address
618    }
619
620    #[test]
621    fn a_seven_bit_address_frames_as_one_shifted_byte() {
622        let bme = seven_bit(0x76);
623        let mut frame = [0u8; PAMOJA_I2C_FRAME_MAX];
624        let mut len = 0usize;
625
626        // Safety: the buffer and the length slot are writable.
627        unsafe {
628            assert_eq!(
629                pamoja_i2c_address_frame(
630                    bme,
631                    PamojaI2cDirection::Write,
632                    frame.as_mut_ptr(),
633                    frame.len(),
634                    &mut len
635                ),
636                PamojaStatus::Ok
637            );
638            assert_eq!((len, frame[0]), (1, 0xEC), "(0x76 << 1) | 0");
639            assert_eq!(
640                pamoja_i2c_address_frame(
641                    bme,
642                    PamojaI2cDirection::Read,
643                    frame.as_mut_ptr(),
644                    frame.len(),
645                    &mut len
646                ),
647                PamojaStatus::Ok
648            );
649            assert_eq!((len, frame[0]), (1, 0xED), "(0x76 << 1) | 1");
650        }
651    }
652
653    #[test]
654    fn a_ten_bit_address_frames_as_the_reserved_prefix_and_the_low_byte() {
655        let mut address = PamojaI2cAddress {
656            value: 0,
657            ten_bit: 0,
658        };
659        let mut frame = [0u8; PAMOJA_I2C_FRAME_MAX];
660        let mut len = 0usize;
661
662        // Safety: the out-pointers and the buffer are writable.
663        unsafe {
664            assert_eq!(
665                pamoja_i2c_address_ten_bit(0x2A5, &mut address),
666                PamojaStatus::Ok
667            );
668            assert_eq!(pamoja_i2c_address_frame_len(address), 2);
669            assert_eq!(
670                pamoja_i2c_address_frame(
671                    address,
672                    PamojaI2cDirection::Write,
673                    frame.as_mut_ptr(),
674                    frame.len(),
675                    &mut len
676                ),
677                PamojaStatus::Ok
678            );
679            assert_eq!(len, 2);
680            assert_eq!(frame, [0xF4, 0xA5], "11110 then the top two bits, then r/w");
681        }
682    }
683
684    #[test]
685    fn an_out_of_range_address_is_refused() {
686        let mut address = PamojaI2cAddress {
687            value: 0,
688            ten_bit: 0,
689        };
690        // Safety: the out-pointer is writable.
691        let status = unsafe { pamoja_i2c_address_ten_bit(0x400, &mut address) };
692        assert_eq!(status, PamojaStatus::InvalidArgument);
693    }
694
695    #[test]
696    fn a_buffer_too_small_for_the_frame_is_refused() {
697        let mut address = PamojaI2cAddress {
698            value: 0,
699            ten_bit: 0,
700        };
701        let mut frame = [0u8; 1];
702        let mut len = 0usize;
703
704        // Safety: the out-pointers and the buffer are writable.
705        unsafe {
706            assert_eq!(
707                pamoja_i2c_address_ten_bit(0x2A5, &mut address),
708                PamojaStatus::Ok
709            );
710            assert_eq!(
711                pamoja_i2c_address_frame(
712                    address,
713                    PamojaI2cDirection::Write,
714                    frame.as_mut_ptr(),
715                    frame.len(),
716                    &mut len
717                ),
718                PamojaStatus::InvalidArgument
719            );
720        }
721    }
722
723    #[test]
724    fn the_reserved_ranges_are_recognized() {
725        assert!(pamoja_i2c_address_is_reserved(seven_bit(0x00)));
726        assert!(pamoja_i2c_address_is_general_call(seven_bit(0x00)));
727        assert!(pamoja_i2c_address_is_reserved(seven_bit(0x07)));
728        assert!(!pamoja_i2c_address_is_reserved(seven_bit(0x08)));
729        assert!(!pamoja_i2c_address_is_reserved(seven_bit(0x77)));
730        assert!(pamoja_i2c_address_is_reserved(seven_bit(0x78)));
731    }
732
733    #[test]
734    fn the_spi_modes_match_the_pairs_datasheets_quote() {
735        let mut cpol = false;
736        let mut cpha = false;
737        for (number, expected) in [
738            (0u8, (false, false)),
739            (1, (false, true)),
740            (2, (true, false)),
741            (3, (true, true)),
742        ] {
743            // Safety: both out-pointers are writable.
744            unsafe {
745                assert_eq!(
746                    pamoja_spi_mode_cpol_cpha(number, &mut cpol, &mut cpha),
747                    PamojaStatus::Ok
748                );
749            }
750            assert_eq!((cpol, cpha), expected, "mode {number}");
751            assert_eq!(pamoja_spi_mode_from_cpol_cpha(cpol, cpha), number);
752        }
753    }
754
755    #[test]
756    fn a_mode_number_above_three_is_refused() {
757        let mut cpol = false;
758        let mut cpha = false;
759        // Safety: both out-pointers are writable.
760        let status = unsafe { pamoja_spi_mode_cpol_cpha(4, &mut cpol, &mut cpha) };
761        assert_eq!(status, PamojaStatus::InvalidArgument);
762    }
763
764    #[test]
765    fn an_active_low_relay_is_energized_by_a_low_level() {
766        assert_eq!(
767            pamoja_pin_polarity_level(PamojaPinPolarity::ActiveLow, true),
768            PamojaPinLevel::Low
769        );
770        assert_eq!(
771            pamoja_pin_polarity_level(PamojaPinPolarity::ActiveHigh, true),
772            PamojaPinLevel::High
773        );
774        assert!(pamoja_pin_polarity_is_asserted(
775            PamojaPinPolarity::ActiveLow,
776            PamojaPinLevel::Low
777        ));
778    }
779
780    #[test]
781    fn an_edge_fires_only_on_its_own_transition() {
782        assert!(pamoja_pin_edge_triggered_by(
783            PamojaPinEdge::Rising,
784            PamojaPinLevel::Low,
785            PamojaPinLevel::High
786        ));
787        assert!(!pamoja_pin_edge_triggered_by(
788            PamojaPinEdge::Rising,
789            PamojaPinLevel::High,
790            PamojaPinLevel::Low
791        ));
792        assert!(pamoja_pin_edge_triggered_by(
793            PamojaPinEdge::Both,
794            PamojaPinLevel::High,
795            PamojaPinLevel::Low
796        ));
797        assert!(!pamoja_pin_edge_triggered_by(
798            PamojaPinEdge::Both,
799            PamojaPinLevel::High,
800            PamojaPinLevel::High
801        ));
802    }
803
804    #[test]
805    fn a_level_inverts() {
806        assert_eq!(
807            pamoja_pin_level_inverted(pamoja_pin_level_from_bool(true)),
808            PamojaPinLevel::Low
809        );
810    }
811
812    fn last_error() -> String {
813        // Safety: every test records a message on this thread before reading it back.
814        unsafe { std::ffi::CStr::from_ptr(crate::pamoja_last_error_message()) }
815            .to_string_lossy()
816            .into_owned()
817    }
818
819    #[test]
820    fn a_line_call_refuses_a_null_argument_by_name() {
821        let chip = std::ffi::CString::new("/dev/gpiochip0").expect("no interior null");
822        let mut level = PamojaPinLevel::Low;
823        // Safety: every pointer is null or writable, and no handle is live.
824        unsafe {
825            assert_eq!(
826                pamoja_gpio_line_open_output(
827                    chip.as_ptr(),
828                    17,
829                    PamojaPinLevel::High,
830                    std::ptr::null_mut()
831                ),
832                PamojaStatus::InvalidArgument
833            );
834            assert!(last_error().contains("out_line"), "{}", last_error());
835            let mut line = std::ptr::null_mut();
836            assert_eq!(
837                pamoja_gpio_line_open_input(std::ptr::null(), 27, &mut line),
838                PamojaStatus::InvalidArgument
839            );
840            assert!(
841                line.is_null() && last_error().contains("chip"),
842                "{}",
843                last_error()
844            );
845            assert_eq!(
846                pamoja_gpio_line_drive(std::ptr::null_mut(), PamojaPinLevel::Low),
847                PamojaStatus::InvalidArgument
848            );
849            assert_eq!(
850                pamoja_gpio_line_read(std::ptr::null_mut(), &mut level),
851                PamojaStatus::InvalidArgument
852            );
853            pamoja_gpio_line_free(std::ptr::null_mut());
854        }
855    }
856
857    #[cfg(not(target_os = "linux"))]
858    #[test]
859    fn opening_a_line_off_linux_is_unsupported() {
860        let chip = std::ffi::CString::new("/dev/gpiochip0").expect("no interior null");
861        let mut line = std::ptr::null_mut();
862        // Safety: the chip is a valid string and the out pointer is writable.
863        let opened = unsafe {
864            pamoja_gpio_line_open_output(chip.as_ptr(), 17, PamojaPinLevel::High, &mut line)
865        };
866        assert_eq!(opened, PamojaStatus::Unsupported);
867        assert!(line.is_null());
868        assert!(last_error().contains("only Linux"), "{}", last_error());
869    }
870
871    #[cfg(target_os = "linux")]
872    #[test]
873    fn opening_a_missing_chip_is_an_io_error_that_names_it() {
874        let chip = std::ffi::CString::new("/dev/gpiochip-pamoja-absent").expect("no interior null");
875        let mut line = std::ptr::null_mut();
876        // Safety: the chip is a valid string and the out pointer is writable.
877        let opened = unsafe { pamoja_gpio_line_open_input(chip.as_ptr(), 27, &mut line) };
878        assert_eq!(opened, PamojaStatus::Io);
879        assert!(line.is_null());
880        assert!(
881            last_error().starts_with("/dev/gpiochip-pamoja-absent line 27: "),
882            "{}",
883            last_error()
884        );
885    }
886}