Skip to main content

pamoja_ffi/
profile.rs

1//! The C ABI for device profiles.
2//!
3//! These wrap [`pamoja_profile`] for callers that reach the SDK through the flat
4//! C boundary. A profile is a named, pre-wired bundle: a control policy, a
5//! publish topic, and a power schedule, so someone who can put a sensor to good
6//! use does not also have to choose algorithms and tuning constants by hand.
7//!
8//! Two things cross. A [`PamojaProfile`] is the manifest, which loads from and
9//! saves to JSON, so a profile ships as a file that a community can publish and
10//! a device can read. A [`PamojaController`] is the decision logic that manifest
11//! describes: hand it a reading and it says what the output should do and
12//! whether the reading crossed a threshold worth raising.
13//!
14//! The whole presentation layer, which declares how a profile appears on a
15//! dashboard, travels inside the manifest JSON rather than as its own set of
16//! calls. That keeps one representation of a profile across every language, and
17//! it is the same JSON the dashboard already consumes.
18//!
19//! Assembling a running node from a profile stays in Rust, because the Rust
20//! `Node` is generic over its sensor, actuator, transport, and codec, and the
21//! four together do not cross a C ABI. Nothing is lost: the controller holds the
22//! decisions, and the caller drives their own hardware around it.
23
24use std::ffi::c_char;
25use std::ptr;
26
27use pamoja_profile::{Alert, ControlSpec, Controller, PowerSchedule, Profile};
28
29use crate::power::PamojaPowerPlan;
30use crate::{read_str, set_last_error, PamojaStatus, PamojaString};
31
32/// Which control policy a profile applies to each reading.
33#[repr(C)]
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum PamojaControlKind {
36    /// Hold a reading near a setpoint by switching an output on and off.
37    Setpoint = 0,
38    /// Watch a falling level and warn before it reaches empty.
39    Level = 1,
40    /// Warn when a reading changes faster than a limit.
41    Surge = 2,
42    /// Report readings only, with no output and no alerts.
43    Monitor = 3,
44}
45
46/// A control policy, flattened so every variant crosses as one value.
47///
48/// Only the fields belonging to `kind` carry meaning; the rest are zero.
49#[repr(C)]
50#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct PamojaControlSpec {
52    /// Which policy this describes.
53    pub kind: PamojaControlKind,
54    /// The target reading, for [`PamojaControlKind::Setpoint`].
55    pub setpoint: f32,
56    /// Half the deadband width, for [`PamojaControlKind::Setpoint`].
57    pub hysteresis: f32,
58    /// Whether the output cools rather than heats, for
59    /// [`PamojaControlKind::Setpoint`].
60    pub cooling: bool,
61    /// How far the reading may stray before an alert, for
62    /// [`PamojaControlKind::Setpoint`].
63    pub safe_band: f32,
64    /// The level treated as empty, for [`PamojaControlKind::Level`].
65    pub empty: f32,
66    /// How many samples ahead to warn, for [`PamojaControlKind::Level`].
67    pub warn_within: u32,
68    /// Whether a rise rather than a fall is watched, for
69    /// [`PamojaControlKind::Surge`].
70    pub rising: bool,
71    /// The largest safe change per sample, for [`PamojaControlKind::Surge`].
72    pub limit: f32,
73}
74
75/// How often a node samples as its battery drains, in whole seconds.
76#[repr(C)]
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct PamojaPowerSchedule {
79    /// Seconds between samples at a healthy charge.
80    pub active_secs: u64,
81    /// Seconds between samples while conserving.
82    pub saver_secs: u64,
83    /// Seconds between samples when critically low.
84    pub critical_secs: u64,
85    /// Enter the saver cadence below this state of charge.
86    pub saver_below: f32,
87    /// Enter the critical cadence below this state of charge.
88    pub critical_below: f32,
89}
90
91/// Which threshold a reading crossed, if any.
92#[repr(C)]
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum PamojaAlertKind {
95    /// The reading raised nothing.
96    None = 0,
97    /// A controlled reading drifted outside its safe band.
98    OutOfRange = 1,
99    /// A falling level will reach empty within a few more samples.
100    RunningOut = 2,
101    /// A reading is changing faster than its safe rate.
102    ChangingFast = 3,
103}
104
105/// What a controller decided about one reading.
106///
107/// Only the field belonging to `alert` carries meaning; the rest are zero.
108#[repr(C)]
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct PamojaReaction {
111    /// Whether the profile drives an output at all.
112    ///
113    /// `false` means the profile observes rather than controls, and `actuator`
114    /// should be ignored.
115    pub has_actuator: bool,
116    /// The setting the output should take, when `has_actuator` is `true`.
117    pub actuator: bool,
118    /// Which threshold the reading crossed.
119    pub alert: PamojaAlertKind,
120    /// The offending reading, for [`PamojaAlertKind::OutOfRange`].
121    pub reading: f32,
122    /// The estimated samples until empty, for [`PamojaAlertKind::RunningOut`].
123    pub samples: u32,
124    /// The change since the previous sample, for
125    /// [`PamojaAlertKind::ChangingFast`].
126    pub rate: f32,
127}
128
129impl From<ControlSpec> for PamojaControlSpec {
130    fn from(spec: ControlSpec) -> Self {
131        let mut flat = Self {
132            kind: PamojaControlKind::Monitor,
133            setpoint: 0.0,
134            hysteresis: 0.0,
135            cooling: false,
136            safe_band: 0.0,
137            empty: 0.0,
138            warn_within: 0,
139            rising: false,
140            limit: 0.0,
141        };
142        match spec {
143            ControlSpec::Setpoint {
144                setpoint,
145                hysteresis,
146                cooling,
147                safe_band,
148            } => {
149                flat.kind = PamojaControlKind::Setpoint;
150                flat.setpoint = setpoint;
151                flat.hysteresis = hysteresis;
152                flat.cooling = cooling;
153                flat.safe_band = safe_band;
154            }
155            ControlSpec::Level { empty, warn_within } => {
156                flat.kind = PamojaControlKind::Level;
157                flat.empty = empty;
158                flat.warn_within = warn_within;
159            }
160            ControlSpec::Surge { rising, limit } => {
161                flat.kind = PamojaControlKind::Surge;
162                flat.rising = rising;
163                flat.limit = limit;
164            }
165            ControlSpec::Monitor => {}
166        }
167        flat
168    }
169}
170
171impl From<PowerSchedule> for PamojaPowerSchedule {
172    fn from(schedule: PowerSchedule) -> Self {
173        Self {
174            active_secs: schedule.active_secs,
175            saver_secs: schedule.saver_secs,
176            critical_secs: schedule.critical_secs,
177            saver_below: schedule.saver_below,
178            critical_below: schedule.critical_below,
179        }
180    }
181}
182
183impl PamojaReaction {
184    /// Flattens a reaction into the shape that crosses the boundary.
185    fn flatten(reaction: pamoja_profile::Reaction) -> Self {
186        let mut flat = Self {
187            has_actuator: reaction.actuator.is_some(),
188            actuator: reaction.actuator.unwrap_or(false),
189            alert: PamojaAlertKind::None,
190            reading: 0.0,
191            samples: 0,
192            rate: 0.0,
193        };
194        match reaction.alert {
195            None => {}
196            Some(Alert::OutOfRange { reading }) => {
197                flat.alert = PamojaAlertKind::OutOfRange;
198                flat.reading = reading;
199            }
200            Some(Alert::RunningOut { samples }) => {
201                flat.alert = PamojaAlertKind::RunningOut;
202                flat.samples = samples;
203            }
204            Some(Alert::ChangingFast { rate }) => {
205                flat.alert = PamojaAlertKind::ChangingFast;
206                flat.rate = rate;
207            }
208        }
209        flat
210    }
211}
212
213/// An opaque handle to a device profile.
214pub struct PamojaProfile {
215    inner: Profile,
216}
217
218impl PamojaProfile {
219    /// Wraps a profile in a handle for the caller to own.
220    fn into_raw(inner: Profile) -> *mut Self {
221        Box::into_raw(Box::new(Self { inner }))
222    }
223}
224
225/// Creates a cold-chain fridge monitor, which holds 5 C and flags an excursion.
226///
227/// # Returns
228///
229/// A handle the caller must release with [`pamoja_profile_free`].
230#[no_mangle]
231pub extern "C" fn pamoja_profile_vaccine_fridge_monitor() -> *mut PamojaProfile {
232    PamojaProfile::into_raw(Profile::vaccine_fridge_monitor())
233}
234
235/// Creates an irrigation node, which opens a valve as soil moisture falls.
236///
237/// # Returns
238///
239/// A handle the caller must release with [`pamoja_profile_free`].
240#[no_mangle]
241pub extern "C" fn pamoja_profile_irrigation_node() -> *mut PamojaProfile {
242    PamojaProfile::into_raw(Profile::irrigation_node())
243}
244
245/// Creates a well-level monitor, which warns before a tank runs dry.
246///
247/// # Returns
248///
249/// A handle the caller must release with [`pamoja_profile_free`].
250#[no_mangle]
251pub extern "C" fn pamoja_profile_well_level() -> *mut PamojaProfile {
252    PamojaProfile::into_raw(Profile::well_level())
253}
254
255/// Creates a flood sensor, which warns when a level rises too fast.
256///
257/// # Returns
258///
259/// A handle the caller must release with [`pamoja_profile_free`].
260#[no_mangle]
261pub extern "C" fn pamoja_profile_flood_sensor() -> *mut PamojaProfile {
262    PamojaProfile::into_raw(Profile::flood_sensor())
263}
264
265/// Loads a profile from its JSON manifest.
266///
267/// # Arguments
268///
269/// * `manifest` - the manifest, as null-terminated UTF-8.
270///
271/// # Returns
272///
273/// A handle the caller must release with [`pamoja_profile_free`], or null if the
274/// manifest is malformed or `manifest` is null, with the reason available from
275/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
276///
277/// # Safety
278///
279/// `manifest` must be a valid null-terminated UTF-8 string for the duration of
280/// the call, or null.
281#[no_mangle]
282pub unsafe extern "C" fn pamoja_profile_from_json(manifest: *const c_char) -> *mut PamojaProfile {
283    let Some(manifest) = read_str(manifest, "manifest") else {
284        return ptr::null_mut();
285    };
286    match Profile::from_json(manifest) {
287        Ok(profile) => PamojaProfile::into_raw(profile),
288        Err(error) => {
289            set_last_error(error.to_string());
290            ptr::null_mut()
291        }
292    }
293}
294
295/// Serializes a profile to its JSON manifest.
296///
297/// # Arguments
298///
299/// * `profile` - the profile.
300///
301/// # Returns
302///
303/// A string the caller must release with
304/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is
305/// null or the profile cannot be serialized.
306///
307/// # Safety
308///
309/// `profile` must be a live handle from a call that produced one, or null.
310#[no_mangle]
311pub unsafe extern "C" fn pamoja_profile_to_json(
312    profile: *const PamojaProfile,
313) -> *mut PamojaString {
314    let Some(profile) = profile_handle(profile) else {
315        return ptr::null_mut();
316    };
317    match profile.inner.to_json() {
318        Ok(manifest) => PamojaString::into_raw(manifest),
319        Err(error) => {
320            set_last_error(error.to_string());
321            ptr::null_mut()
322        }
323    }
324}
325
326/// Returns a profile's stable name.
327///
328/// # Arguments
329///
330/// * `profile` - the profile.
331///
332/// # Returns
333///
334/// A null-terminated UTF-8 string, which the caller must release with
335/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is
336/// null.
337///
338/// # Safety
339///
340/// `profile` must be a live handle from a call that produced one, or null.
341#[no_mangle]
342pub unsafe extern "C" fn pamoja_profile_name(profile: *const PamojaProfile) -> *mut PamojaString {
343    match profile_handle(profile) {
344        Some(profile) => PamojaString::into_raw(profile.inner.name.clone()),
345        None => ptr::null_mut(),
346    }
347}
348
349/// Returns the topic a profile publishes each reading to.
350///
351/// # Arguments
352///
353/// * `profile` - the profile.
354///
355/// # Returns
356///
357/// A null-terminated UTF-8 string, which the caller must release with
358/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is
359/// null.
360///
361/// # Safety
362///
363/// `profile` must be a live handle from a call that produced one, or null.
364#[no_mangle]
365pub unsafe extern "C" fn pamoja_profile_topic(profile: *const PamojaProfile) -> *mut PamojaString {
366    match profile_handle(profile) {
367        Some(profile) => PamojaString::into_raw(profile.inner.topic.clone()),
368        None => ptr::null_mut(),
369    }
370}
371
372/// Returns the control policy a profile applies.
373///
374/// # Arguments
375///
376/// * `profile` - the profile.
377/// * `out_control` - receives the policy.
378///
379/// # Returns
380///
381/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
382/// either pointer is null.
383///
384/// # Safety
385///
386/// `profile` must be a live handle from a call that produced one, and
387/// `out_control` must be writable.
388#[no_mangle]
389pub unsafe extern "C" fn pamoja_profile_control(
390    profile: *const PamojaProfile,
391    out_control: *mut PamojaControlSpec,
392) -> PamojaStatus {
393    if out_control.is_null() {
394        set_last_error("out_control must not be null".to_owned());
395        return PamojaStatus::InvalidArgument;
396    }
397    let Some(profile) = profile_handle(profile) else {
398        return PamojaStatus::InvalidArgument;
399    };
400    *out_control = profile.inner.control.into();
401    PamojaStatus::Ok
402}
403
404/// Returns the sampling schedule a profile keeps as its battery drains.
405///
406/// # Arguments
407///
408/// * `profile` - the profile.
409/// * `out_schedule` - receives the schedule.
410///
411/// # Returns
412///
413/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
414/// either pointer is null.
415///
416/// # Safety
417///
418/// `profile` must be a live handle from a call that produced one, and
419/// `out_schedule` must be writable.
420#[no_mangle]
421pub unsafe extern "C" fn pamoja_profile_power(
422    profile: *const PamojaProfile,
423    out_schedule: *mut PamojaPowerSchedule,
424) -> PamojaStatus {
425    if out_schedule.is_null() {
426        set_last_error("out_schedule must not be null".to_owned());
427        return PamojaStatus::InvalidArgument;
428    }
429    let Some(profile) = profile_handle(profile) else {
430        return PamojaStatus::InvalidArgument;
431    };
432    *out_schedule = profile.inner.power.into();
433    PamojaStatus::Ok
434}
435
436/// Assembles a profile's schedule into a power governor.
437///
438/// The governor is the same one [`pamoja_power_plan_new`](crate::power::pamoja_power_plan_new)
439/// builds, so the mode and interval calls in that module apply to it unchanged.
440///
441/// # Arguments
442///
443/// * `profile` - the profile.
444/// * `out_plan` - receives the governor.
445///
446/// # Returns
447///
448/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
449/// either pointer is null.
450///
451/// # Safety
452///
453/// `profile` must be a live handle from a call that produced one, and `out_plan`
454/// must be writable.
455#[no_mangle]
456pub unsafe extern "C" fn pamoja_profile_power_plan(
457    profile: *const PamojaProfile,
458    out_plan: *mut PamojaPowerPlan,
459) -> PamojaStatus {
460    if out_plan.is_null() {
461        set_last_error("out_plan must not be null".to_owned());
462        return PamojaStatus::InvalidArgument;
463    }
464    let Some(profile) = profile_handle(profile) else {
465        return PamojaStatus::InvalidArgument;
466    };
467    let schedule = profile.inner.power;
468    *out_plan = PamojaPowerPlan {
469        active_us: schedule.active_secs.saturating_mul(1_000_000),
470        saver_us: schedule.saver_secs.saturating_mul(1_000_000),
471        critical_us: schedule.critical_secs.saturating_mul(1_000_000),
472        saver_below: schedule.saver_below,
473        critical_below: schedule.critical_below,
474    };
475    PamojaStatus::Ok
476}
477
478/// Builds the decision logic a profile describes.
479///
480/// # Arguments
481///
482/// * `profile` - the profile.
483///
484/// # Returns
485///
486/// A handle the caller must release with [`pamoja_controller_free`], or null if
487/// `profile` is null.
488///
489/// # Safety
490///
491/// `profile` must be a live handle from a call that produced one, or null.
492#[no_mangle]
493pub unsafe extern "C" fn pamoja_profile_controller(
494    profile: *const PamojaProfile,
495) -> *mut PamojaController {
496    match profile_handle(profile) {
497        Some(profile) => PamojaController::into_raw(profile.inner.controller()),
498        None => ptr::null_mut(),
499    }
500}
501
502/// Releases a profile handle.
503///
504/// Passing null is a no-op.
505///
506/// # Safety
507///
508/// `profile` must be a handle from a call that produced one and that has not
509/// already been freed, or null. After this call it must not be used again.
510#[no_mangle]
511pub unsafe extern "C" fn pamoja_profile_free(profile: *mut PamojaProfile) {
512    if !profile.is_null() {
513        drop(Box::from_raw(profile));
514    }
515}
516
517/// An opaque handle to a profile's decision logic.
518///
519/// A controller carries state between readings, because a level estimate and a
520/// rate of change both need the previous sample, so evaluate readings through
521/// one controller in the order they were taken.
522pub struct PamojaController {
523    inner: Controller,
524}
525
526impl PamojaController {
527    /// Wraps a controller in a handle for the caller to own.
528    fn into_raw(inner: Controller) -> *mut Self {
529        Box::into_raw(Box::new(Self { inner }))
530    }
531}
532
533/// Creates a controller that holds a reading near a setpoint.
534///
535/// # Arguments
536///
537/// * `setpoint` - the target reading.
538/// * `hysteresis` - half the deadband width, which stops the output chattering.
539/// * `cooling` - whether the output cools rather than heats.
540/// * `safe_band` - how far the reading may stray before an alert.
541///
542/// # Returns
543///
544/// A handle the caller must release with [`pamoja_controller_free`].
545#[no_mangle]
546pub extern "C" fn pamoja_controller_setpoint(
547    setpoint: f32,
548    hysteresis: f32,
549    cooling: bool,
550    safe_band: f32,
551) -> *mut PamojaController {
552    PamojaController::into_raw(Controller::setpoint(
553        setpoint, hysteresis, cooling, safe_band,
554    ))
555}
556
557/// Creates a controller that warns before a falling level reaches empty.
558///
559/// # Arguments
560///
561/// * `empty` - the level treated as empty.
562/// * `warn_within` - warn once empty is this many samples away.
563///
564/// # Returns
565///
566/// A handle the caller must release with [`pamoja_controller_free`].
567#[no_mangle]
568pub extern "C" fn pamoja_controller_level(empty: f32, warn_within: u32) -> *mut PamojaController {
569    PamojaController::into_raw(Controller::level(empty, warn_within))
570}
571
572/// Creates a controller that warns when a reading changes too fast.
573///
574/// # Arguments
575///
576/// * `rising` - watch a rapid rise rather than a rapid fall.
577/// * `limit` - the largest safe change per sample.
578///
579/// # Returns
580///
581/// A handle the caller must release with [`pamoja_controller_free`].
582#[no_mangle]
583pub extern "C" fn pamoja_controller_surge(rising: bool, limit: f32) -> *mut PamojaController {
584    PamojaController::into_raw(Controller::surge(rising, limit))
585}
586
587/// Creates a controller that reports readings without judging them.
588///
589/// # Returns
590///
591/// A handle the caller must release with [`pamoja_controller_free`].
592#[no_mangle]
593pub extern "C" fn pamoja_controller_monitor() -> *mut PamojaController {
594    PamojaController::into_raw(Controller::monitor())
595}
596
597/// Decides what one reading calls for.
598///
599/// # Arguments
600///
601/// * `controller` - the decision logic.
602/// * `reading` - the reading to evaluate.
603/// * `out_reaction` - receives the decision.
604///
605/// # Returns
606///
607/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
608/// either pointer is null.
609///
610/// # Safety
611///
612/// `controller` must be a live handle from a call that produced one, and
613/// `out_reaction` must be writable.
614#[no_mangle]
615pub unsafe extern "C" fn pamoja_controller_evaluate(
616    controller: *mut PamojaController,
617    reading: f32,
618    out_reaction: *mut PamojaReaction,
619) -> PamojaStatus {
620    if controller.is_null() {
621        set_last_error("controller must not be null".to_owned());
622        return PamojaStatus::InvalidArgument;
623    }
624    if out_reaction.is_null() {
625        set_last_error("out_reaction must not be null".to_owned());
626        return PamojaStatus::InvalidArgument;
627    }
628    let controller = &mut *controller;
629    *out_reaction = PamojaReaction::flatten(controller.inner.evaluate(reading));
630    PamojaStatus::Ok
631}
632
633/// Releases a controller handle.
634///
635/// Passing null is a no-op.
636///
637/// # Safety
638///
639/// `controller` must be a handle from a call that produced one and that has not
640/// already been freed, or null. After this call it must not be used again.
641#[no_mangle]
642pub unsafe extern "C" fn pamoja_controller_free(controller: *mut PamojaController) {
643    if !controller.is_null() {
644        drop(Box::from_raw(controller));
645    }
646}
647
648/// Borrows a profile handle, rejecting a null pointer.
649///
650/// # Safety
651///
652/// `profile` must be a live handle from a call that produced one, or null.
653unsafe fn profile_handle<'a>(profile: *const PamojaProfile) -> Option<&'a PamojaProfile> {
654    if profile.is_null() {
655        set_last_error("profile must not be null".to_owned());
656        return None;
657    }
658    Some(&*profile)
659}
660
661#[cfg(test)]
662mod tests {
663    use std::ffi::{CStr, CString};
664
665    use super::*;
666
667    fn text_of(string: *mut PamojaString) -> String {
668        assert!(!string.is_null(), "the call produced no string");
669        let text = unsafe { CStr::from_ptr(crate::pamoja_string_data(string)) }
670            .to_str()
671            .expect("utf-8")
672            .to_owned();
673        unsafe { crate::pamoja_string_free(string) };
674        text
675    }
676
677    fn reaction_for(controller: *mut PamojaController, reading: f32) -> PamojaReaction {
678        let mut reaction = PamojaReaction {
679            has_actuator: false,
680            actuator: false,
681            alert: PamojaAlertKind::None,
682            reading: 0.0,
683            samples: 0,
684            rate: 0.0,
685        };
686        assert_eq!(
687            unsafe { pamoja_controller_evaluate(controller, reading, &mut reaction) },
688            PamojaStatus::Ok
689        );
690        reaction
691    }
692
693    #[test]
694    fn a_warm_fridge_runs_the_cooler_and_flags_the_excursion() {
695        let profile = pamoja_profile_vaccine_fridge_monitor();
696        let controller = unsafe { pamoja_profile_controller(profile) };
697        let reaction = reaction_for(controller, 9.0);
698
699        assert!(reaction.has_actuator, "the profile drives a cooler");
700        assert!(reaction.actuator, "which runs when the fridge is warm");
701        assert_eq!(
702            reaction.alert,
703            PamojaAlertKind::OutOfRange,
704            "and 9 C is outside the safe band"
705        );
706        assert_eq!(reaction.reading, 9.0);
707
708        unsafe {
709            pamoja_controller_free(controller);
710            pamoja_profile_free(profile);
711        }
712    }
713
714    #[test]
715    fn a_monitoring_profile_drives_no_output() {
716        let controller = pamoja_controller_monitor();
717        let reaction = reaction_for(controller, 21.5);
718        assert!(
719            !reaction.has_actuator,
720            "a monitor observes rather than acts"
721        );
722        assert_eq!(reaction.alert, PamojaAlertKind::None);
723        unsafe { pamoja_controller_free(controller) };
724    }
725
726    #[test]
727    fn a_manifest_survives_a_round_trip() {
728        let profile = pamoja_profile_well_level();
729        let manifest = text_of(unsafe { pamoja_profile_to_json(profile) });
730
731        let text = CString::new(manifest).expect("no interior null");
732        let loaded = unsafe { pamoja_profile_from_json(text.as_ptr()) };
733        assert!(!loaded.is_null());
734
735        assert_eq!(
736            text_of(unsafe { pamoja_profile_name(loaded) }),
737            text_of(unsafe { pamoja_profile_name(profile) })
738        );
739
740        let mut original = PamojaControlSpec::from(ControlSpec::Monitor);
741        let mut restored = original;
742        unsafe {
743            assert_eq!(
744                pamoja_profile_control(profile, &mut original),
745                PamojaStatus::Ok
746            );
747            assert_eq!(
748                pamoja_profile_control(loaded, &mut restored),
749                PamojaStatus::Ok
750            );
751            pamoja_profile_free(loaded);
752            pamoja_profile_free(profile);
753        }
754        assert_eq!(restored.kind, PamojaControlKind::Level);
755        assert_eq!(restored, original, "the policy came back unchanged");
756    }
757
758    #[test]
759    fn a_schedule_becomes_a_governor_in_microseconds() {
760        let profile = pamoja_profile_flood_sensor();
761        let mut schedule = PamojaPowerSchedule {
762            active_secs: 0,
763            saver_secs: 0,
764            critical_secs: 0,
765            saver_below: 0.0,
766            critical_below: 0.0,
767        };
768        let mut plan = PamojaPowerPlan {
769            active_us: 0,
770            saver_us: 0,
771            critical_us: 0,
772            saver_below: 0.0,
773            critical_below: 0.0,
774        };
775        unsafe {
776            assert_eq!(
777                pamoja_profile_power(profile, &mut schedule),
778                PamojaStatus::Ok
779            );
780            assert_eq!(
781                pamoja_profile_power_plan(profile, &mut plan),
782                PamojaStatus::Ok
783            );
784            pamoja_profile_free(profile);
785        }
786        assert_eq!(plan.active_us, schedule.active_secs * 1_000_000);
787        assert_eq!(plan.saver_below, schedule.saver_below);
788    }
789
790    #[test]
791    fn a_null_argument_is_rejected_rather_than_dereferenced() {
792        assert!(unsafe { pamoja_profile_from_json(ptr::null()) }.is_null());
793        assert!(unsafe { pamoja_profile_to_json(ptr::null()) }.is_null());
794        assert!(unsafe { pamoja_profile_name(ptr::null()) }.is_null());
795        assert!(unsafe { pamoja_profile_topic(ptr::null()) }.is_null());
796        assert!(unsafe { pamoja_profile_controller(ptr::null()) }.is_null());
797        assert_eq!(
798            unsafe { pamoja_profile_control(ptr::null(), ptr::null_mut()) },
799            PamojaStatus::InvalidArgument
800        );
801        assert_eq!(
802            unsafe { pamoja_controller_evaluate(ptr::null_mut(), 0.0, ptr::null_mut()) },
803            PamojaStatus::InvalidArgument
804        );
805    }
806}