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 presentation layer, which declares how a profile appears on a dashboard,
15//! crosses as the JSON object a manifest carries under `presentation`, through
16//! [`pamoja_profile_presentation_json`] and [`pamoja_profile_with_presentation_json`].
17//! That keeps one representation of a profile across every language, and it is
18//! the same JSON the dashboard already consumes; each binding gives it a typed
19//! shape on its own side.
20//!
21//! Assembling a running node from a profile stays in Rust, because the Rust
22//! `Node` is generic over its sensor, actuator, transport, and codec, and the
23//! four together do not cross a C ABI. Nothing is lost: the controller holds the
24//! decisions, and the caller drives their own hardware around it.
25
26use std::ffi::c_char;
27use std::ptr;
28
29use pamoja_profile::{
30    Alert, ControlSpec, Controller, Params, PowerSchedule, Presentation, Profile,
31};
32
33use crate::power::PamojaPowerPlan;
34use crate::{read_str, set_last_error, PamojaStatus, PamojaString};
35
36/// Which control policy a profile applies to each reading.
37#[repr(C)]
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum PamojaControlKind {
40    /// Hold a reading near a setpoint by switching an output on and off.
41    Setpoint = 0,
42    /// Watch a falling level and warn before it reaches empty.
43    Level = 1,
44    /// Warn when a reading changes faster than a limit.
45    Surge = 2,
46    /// Report readings only, with no output and no alerts.
47    Monitor = 3,
48    /// A kind the library does not ship, decided by code the host registers; its
49    /// name and parameters come from [`pamoja_profile_control_kind`] and
50    /// [`pamoja_profile_control_params_json`].
51    Custom = 4,
52}
53
54/// The most bytes a custom alert's code carries across the boundary, terminator
55/// included; a longer code is cut to fit.
56pub const PAMOJA_ALERT_CODE_LEN: usize = 32;
57
58/// A control policy, flattened so every variant crosses as one value.
59///
60/// Only the fields belonging to `kind` carry meaning; the rest are zero.
61#[repr(C)]
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct PamojaControlSpec {
64    /// Which policy this describes.
65    pub kind: PamojaControlKind,
66    /// The target reading, for [`PamojaControlKind::Setpoint`].
67    pub setpoint: f32,
68    /// Half the deadband width, for [`PamojaControlKind::Setpoint`].
69    pub hysteresis: f32,
70    /// Whether the output cools rather than heats, for
71    /// [`PamojaControlKind::Setpoint`].
72    pub cooling: bool,
73    /// How far the reading may stray before an alert, for
74    /// [`PamojaControlKind::Setpoint`].
75    pub safe_band: f32,
76    /// The level treated as empty, for [`PamojaControlKind::Level`].
77    pub empty: f32,
78    /// How many samples ahead to warn, for [`PamojaControlKind::Level`].
79    pub warn_within: u32,
80    /// Whether a rise rather than a fall is watched, for
81    /// [`PamojaControlKind::Surge`].
82    pub rising: bool,
83    /// The largest safe change per sample, for [`PamojaControlKind::Surge`].
84    pub limit: f32,
85}
86
87/// How often a node samples as its battery drains, in whole seconds.
88#[repr(C)]
89#[derive(Clone, Copy, Debug, PartialEq)]
90pub struct PamojaPowerSchedule {
91    /// Seconds between samples at a healthy charge.
92    pub active_secs: u64,
93    /// Seconds between samples while conserving.
94    pub saver_secs: u64,
95    /// Seconds between samples when critically low.
96    pub critical_secs: u64,
97    /// Enter the saver cadence below this state of charge.
98    pub saver_below: f32,
99    /// Enter the critical cadence below this state of charge.
100    pub critical_below: f32,
101    /// How far above a threshold the charge must climb to leave the lower cadence.
102    pub hysteresis: f32,
103}
104
105/// Which threshold a reading crossed, if any.
106#[repr(C)]
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum PamojaAlertKind {
109    /// The reading raised nothing.
110    None = 0,
111    /// A controlled reading drifted outside its safe band.
112    OutOfRange = 1,
113    /// A falling level will reach empty within a few more samples.
114    RunningOut = 2,
115    /// A reading is changing faster than its safe rate.
116    ChangingFast = 3,
117    /// A condition a policy of the host's own raised, named by `code`.
118    Custom = 4,
119    /// A reading that is not a finite number, such as the NaN a failed probe produces.
120    InvalidReading = 5,
121}
122
123/// What a controller decided about one reading.
124///
125/// Only the field belonging to `alert` carries meaning; the rest are zero.
126#[repr(C)]
127#[derive(Clone, Copy, Debug, PartialEq)]
128pub struct PamojaReaction {
129    /// Whether the profile drives an output at all.
130    ///
131    /// `false` means the profile observes rather than controls, and `actuator`
132    /// should be ignored.
133    pub has_actuator: bool,
134    /// The setting the output should take, when `has_actuator` is `true`.
135    pub actuator: bool,
136    /// Which threshold the reading crossed.
137    pub alert: PamojaAlertKind,
138    /// The offending reading, for [`PamojaAlertKind::OutOfRange`] and
139    /// [`PamojaAlertKind::InvalidReading`].
140    pub reading: f32,
141    /// The estimated samples until empty, for [`PamojaAlertKind::RunningOut`].
142    pub samples: u32,
143    /// The change since the previous sample, for
144    /// [`PamojaAlertKind::ChangingFast`].
145    pub rate: f32,
146    /// The condition's name, null-terminated, for [`PamojaAlertKind::Custom`].
147    pub code: [c_char; PAMOJA_ALERT_CODE_LEN],
148    /// The measurement behind the condition, for [`PamojaAlertKind::Custom`].
149    pub value: f32,
150}
151
152impl From<&ControlSpec> for PamojaControlSpec {
153    fn from(spec: &ControlSpec) -> Self {
154        let mut flat = Self {
155            kind: PamojaControlKind::Monitor,
156            setpoint: 0.0,
157            hysteresis: 0.0,
158            cooling: false,
159            safe_band: 0.0,
160            empty: 0.0,
161            warn_within: 0,
162            rising: false,
163            limit: 0.0,
164        };
165        match *spec {
166            ControlSpec::Setpoint {
167                setpoint,
168                hysteresis,
169                cooling,
170                safe_band,
171            } => {
172                flat.kind = PamojaControlKind::Setpoint;
173                flat.setpoint = setpoint;
174                flat.hysteresis = hysteresis;
175                flat.cooling = cooling;
176                flat.safe_band = safe_band;
177            }
178            ControlSpec::Level { empty, warn_within } => {
179                flat.kind = PamojaControlKind::Level;
180                flat.empty = empty;
181                flat.warn_within = warn_within;
182            }
183            ControlSpec::Surge { rising, limit } => {
184                flat.kind = PamojaControlKind::Surge;
185                flat.rising = rising;
186                flat.limit = limit;
187            }
188            ControlSpec::Monitor => {}
189            ControlSpec::Custom { .. } => flat.kind = PamojaControlKind::Custom,
190        }
191        flat
192    }
193}
194
195impl From<PowerSchedule> for PamojaPowerSchedule {
196    fn from(schedule: PowerSchedule) -> Self {
197        Self {
198            active_secs: schedule.active_secs,
199            saver_secs: schedule.saver_secs,
200            critical_secs: schedule.critical_secs,
201            saver_below: schedule.saver_below,
202            critical_below: schedule.critical_below,
203            hysteresis: schedule.hysteresis,
204        }
205    }
206}
207
208impl PamojaReaction {
209    /// Flattens a reaction into the shape that crosses the boundary.
210    fn flatten(reaction: pamoja_profile::Reaction) -> Self {
211        let mut flat = Self {
212            has_actuator: reaction.actuator.is_some(),
213            actuator: reaction.actuator.unwrap_or(false),
214            alert: PamojaAlertKind::None,
215            reading: 0.0,
216            samples: 0,
217            rate: 0.0,
218            code: [0; PAMOJA_ALERT_CODE_LEN],
219            value: 0.0,
220        };
221        match reaction.alert {
222            None => {}
223            Some(Alert::OutOfRange { reading }) => {
224                flat.alert = PamojaAlertKind::OutOfRange;
225                flat.reading = reading;
226            }
227            Some(Alert::RunningOut { samples }) => {
228                flat.alert = PamojaAlertKind::RunningOut;
229                flat.samples = samples;
230            }
231            Some(Alert::ChangingFast { rate }) => {
232                flat.alert = PamojaAlertKind::ChangingFast;
233                flat.rate = rate;
234            }
235            Some(Alert::InvalidReading { reading }) => {
236                flat.alert = PamojaAlertKind::InvalidReading;
237                flat.reading = reading;
238            }
239            Some(Alert::Custom { code, value }) => {
240                flat.alert = PamojaAlertKind::Custom;
241                flat.value = value;
242                for (slot, byte) in flat
243                    .code
244                    .iter_mut()
245                    .zip(code.bytes().take(PAMOJA_ALERT_CODE_LEN - 1))
246                {
247                    *slot = byte as c_char;
248                }
249            }
250        }
251        flat
252    }
253}
254
255/// An opaque handle to a device profile.
256pub struct PamojaProfile {
257    inner: Profile,
258}
259
260impl PamojaProfile {
261    /// Wraps a profile in a handle for the caller to own.
262    fn into_raw(inner: Profile) -> *mut Self {
263        Box::into_raw(Box::new(Self { inner }))
264    }
265}
266
267/// Creates a cold-chain fridge monitor, which holds 5 C and flags an excursion.
268///
269/// # Returns
270///
271/// A handle the caller must release with [`pamoja_profile_free`].
272#[no_mangle]
273pub extern "C" fn pamoja_profile_vaccine_fridge_monitor() -> *mut PamojaProfile {
274    PamojaProfile::into_raw(Profile::vaccine_fridge_monitor())
275}
276
277/// Creates an irrigation node, which opens a valve as soil moisture falls.
278///
279/// # Returns
280///
281/// A handle the caller must release with [`pamoja_profile_free`].
282#[no_mangle]
283pub extern "C" fn pamoja_profile_irrigation_node() -> *mut PamojaProfile {
284    PamojaProfile::into_raw(Profile::irrigation_node())
285}
286
287/// Creates a well-level monitor, which warns before a tank runs dry.
288///
289/// # Returns
290///
291/// A handle the caller must release with [`pamoja_profile_free`].
292#[no_mangle]
293pub extern "C" fn pamoja_profile_well_level() -> *mut PamojaProfile {
294    PamojaProfile::into_raw(Profile::well_level())
295}
296
297/// Creates a flood sensor, which warns when a level rises too fast.
298///
299/// # Returns
300///
301/// A handle the caller must release with [`pamoja_profile_free`].
302#[no_mangle]
303pub extern "C" fn pamoja_profile_flood_sensor() -> *mut PamojaProfile {
304    PamojaProfile::into_raw(Profile::flood_sensor())
305}
306
307/// Loads a profile from its JSON manifest.
308///
309/// # Arguments
310///
311/// * `manifest` - the manifest, as null-terminated UTF-8.
312///
313/// # Returns
314///
315/// A handle the caller must release with [`pamoja_profile_free`], or null if the
316/// manifest is malformed or `manifest` is null, with the reason available from
317/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
318///
319/// # Safety
320///
321/// `manifest` must be a valid null-terminated UTF-8 string for the duration of
322/// the call, or null.
323#[no_mangle]
324pub unsafe extern "C" fn pamoja_profile_from_json(manifest: *const c_char) -> *mut PamojaProfile {
325    let Some(manifest) = read_str(manifest, "manifest") else {
326        return ptr::null_mut();
327    };
328    match Profile::from_json(manifest) {
329        Ok(profile) => PamojaProfile::into_raw(profile),
330        Err(error) => {
331            set_last_error(error.to_string());
332            ptr::null_mut()
333        }
334    }
335}
336
337/// Creates a profile of the host's own from its parts, with no description and no
338/// presentation.
339///
340/// # Arguments
341///
342/// * `name` - the profile's name, as null-terminated UTF-8.
343/// * `topic` - the topic each reading is published to, as null-terminated UTF-8.
344/// * `control` - the control policy; only the fields belonging to its kind are read.
345///   A custom kind carries parameters this call has no room for, so it is refused:
346///   build such a profile with [`pamoja_profile_new_custom`].
347/// * `power` - the sampling schedule.
348///
349/// # Returns
350///
351/// A handle the caller must release with [`pamoja_profile_free`], or null if a
352/// pointer is null, a string is not UTF-8, the control kind is custom or not one this
353/// library knows, or the profile is one no node could run, such as a hysteresis of
354/// zero or intervals that shorten as the battery drains, with the reason available
355/// from [`pamoja_last_error_message`](crate::pamoja_last_error_message).
356///
357/// # Safety
358///
359/// `name` and `topic` must be valid null-terminated UTF-8 strings for the duration of
360/// the call, and `control` and `power` must point at readable values; any may be null.
361#[no_mangle]
362pub unsafe extern "C" fn pamoja_profile_new(
363    name: *const c_char,
364    topic: *const c_char,
365    control: *const PamojaControlSpec,
366    power: *const PamojaPowerSchedule,
367) -> *mut PamojaProfile {
368    let Some(name) = read_str(name, "name") else {
369        return ptr::null_mut();
370    };
371    let Some(topic) = read_str(topic, "topic") else {
372        return ptr::null_mut();
373    };
374    if power.is_null() {
375        set_last_error("power must not be null".to_owned());
376        return ptr::null_mut();
377    }
378    let control = match read_control(control) {
379        Ok(control) => control,
380        Err(reason) => {
381            set_last_error(reason);
382            return ptr::null_mut();
383        }
384    };
385    checked(Profile::new(name, topic, control, schedule_of(*power)))
386}
387
388/// Hands a profile to the caller once it passes its check, or records why it does not.
389fn checked(profile: Profile) -> *mut PamojaProfile {
390    match profile.check() {
391        Ok(()) => PamojaProfile::into_raw(profile),
392        Err(error) => {
393            set_last_error(error.to_string());
394            ptr::null_mut()
395        }
396    }
397}
398
399/// Creates a profile of the host's own whose control is a kind the library does not
400/// ship, decided by code the host registers.
401///
402/// # Arguments
403///
404/// * `name` - the profile's name, as null-terminated UTF-8.
405/// * `topic` - the topic each reading is published to, as null-terminated UTF-8.
406/// * `kind` - the kind as the manifest will name it, as null-terminated UTF-8.
407/// * `params` - the policy's other fields, as the JSON object a manifest carries beside
408///   `kind`, each value a number, `true` or `false`, or text, as null-terminated UTF-8.
409/// * `power` - the sampling schedule.
410///
411/// # Returns
412///
413/// A handle the caller must release with [`pamoja_profile_free`], or null if a
414/// pointer is null, a string is not UTF-8, `params` is not such an object, the kind is
415/// empty, names a built-in kind, or `params` holds a field named `kind`, or the name,
416/// topic, or schedule is one no node could run, with the reason available from
417/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
418///
419/// # Safety
420///
421/// `name`, `topic`, `kind`, and `params` must be valid null-terminated UTF-8 strings for
422/// the duration of the call, and `power` must point at a readable value; any may be null.
423#[no_mangle]
424pub unsafe extern "C" fn pamoja_profile_new_custom(
425    name: *const c_char,
426    topic: *const c_char,
427    kind: *const c_char,
428    params: *const c_char,
429    power: *const PamojaPowerSchedule,
430) -> *mut PamojaProfile {
431    let (Some(name), Some(topic), Some(kind), Some(params)) = (
432        read_str(name, "name"),
433        read_str(topic, "topic"),
434        read_str(kind, "kind"),
435        read_str(params, "params"),
436    ) else {
437        return ptr::null_mut();
438    };
439    if power.is_null() {
440        set_last_error("power must not be null".to_owned());
441        return ptr::null_mut();
442    }
443    let control = Params::from_json(params).and_then(|params| ControlSpec::custom(kind, params));
444    match control {
445        Ok(control) => checked(Profile::new(name, topic, control, schedule_of(*power))),
446        Err(error) => {
447            set_last_error(error.to_string());
448            ptr::null_mut()
449        }
450    }
451}
452
453/// Rebuilds a schedule from the one the host wrote.
454fn schedule_of(power: PamojaPowerSchedule) -> PowerSchedule {
455    PowerSchedule {
456        active_secs: power.active_secs,
457        saver_secs: power.saver_secs,
458        critical_secs: power.critical_secs,
459        saver_below: power.saver_below,
460        critical_below: power.critical_below,
461        hysteresis: power.hysteresis,
462    }
463}
464
465/// Reads a control policy the host wrote, checking its kind before trusting it.
466///
467/// The kind is read as the integer the host stored and the flags as the bytes it
468/// stored, so a value outside the enum or a flag other than 0 or 1 is refused or read
469/// as a C truth value rather than becoming a Rust enum or `bool` it cannot be.
470///
471/// # Safety
472///
473/// `control` must point at a readable [`PamojaControlSpec`], or be null.
474unsafe fn read_control(control: *const PamojaControlSpec) -> Result<ControlSpec, String> {
475    if control.is_null() {
476        return Err("control must not be null".to_owned());
477    }
478    let kind = ptr::addr_of!((*control).kind).cast::<u32>().read();
479    let flag = |field: *const bool| field.cast::<u8>().read() != 0;
480    match kind {
481        0 => Ok(ControlSpec::Setpoint {
482            setpoint: (*control).setpoint,
483            hysteresis: (*control).hysteresis,
484            cooling: flag(ptr::addr_of!((*control).cooling)),
485            safe_band: (*control).safe_band,
486        }),
487        1 => Ok(ControlSpec::Level {
488            empty: (*control).empty,
489            warn_within: (*control).warn_within,
490        }),
491        2 => Ok(ControlSpec::Surge {
492            rising: flag(ptr::addr_of!((*control).rising)),
493            limit: (*control).limit,
494        }),
495        3 => Ok(ControlSpec::Monitor),
496        4 => Err(
497            "a custom control carries parameters of its own, so build it with pamoja_profile_new_custom"
498                .to_owned(),
499        ),
500        other => Err(format!("{other} is not a control kind")),
501    }
502}
503
504/// Serializes a profile to its JSON manifest.
505///
506/// # Arguments
507///
508/// * `profile` - the profile.
509///
510/// # Returns
511///
512/// A string the caller must release with
513/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is
514/// null or the profile cannot be serialized.
515///
516/// # Safety
517///
518/// `profile` must be a live handle from a call that produced one, or null.
519#[no_mangle]
520pub unsafe extern "C" fn pamoja_profile_to_json(
521    profile: *const PamojaProfile,
522) -> *mut PamojaString {
523    let Some(profile) = profile_handle(profile) else {
524        return ptr::null_mut();
525    };
526    match profile.inner.to_json() {
527        Ok(manifest) => PamojaString::into_raw(manifest),
528        Err(error) => {
529            set_last_error(error.to_string());
530            ptr::null_mut()
531        }
532    }
533}
534
535/// Returns a profile's stable name.
536///
537/// # Arguments
538///
539/// * `profile` - the profile.
540///
541/// # Returns
542///
543/// A null-terminated UTF-8 string, which the caller must release with
544/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is
545/// null.
546///
547/// # Safety
548///
549/// `profile` must be a live handle from a call that produced one, or null.
550#[no_mangle]
551pub unsafe extern "C" fn pamoja_profile_name(profile: *const PamojaProfile) -> *mut PamojaString {
552    match profile_handle(profile) {
553        Some(profile) => PamojaString::into_raw(profile.inner.name.clone()),
554        None => ptr::null_mut(),
555    }
556}
557
558/// Returns the topic a profile publishes each reading to.
559///
560/// # Arguments
561///
562/// * `profile` - the profile.
563///
564/// # Returns
565///
566/// A null-terminated UTF-8 string, which the caller must release with
567/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is
568/// null.
569///
570/// # Safety
571///
572/// `profile` must be a live handle from a call that produced one, or null.
573#[no_mangle]
574pub unsafe extern "C" fn pamoja_profile_topic(profile: *const PamojaProfile) -> *mut PamojaString {
575    match profile_handle(profile) {
576        Some(profile) => PamojaString::into_raw(profile.inner.topic.clone()),
577        None => ptr::null_mut(),
578    }
579}
580
581/// Returns what a profile is for, in the words its manifest carries.
582///
583/// # Arguments
584///
585/// * `profile` - the profile.
586///
587/// # Returns
588///
589/// A null-terminated UTF-8 string, which the caller must release with
590/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the profile
591/// carries no description or `profile` is null. The two cases are told apart by
592/// [`pamoja_last_error_message`](crate::pamoja_last_error_message), which is set
593/// only for a null handle.
594///
595/// # Safety
596///
597/// `profile` must be a live handle from a call that produced one, or null.
598#[no_mangle]
599pub unsafe extern "C" fn pamoja_profile_description(
600    profile: *const PamojaProfile,
601) -> *mut PamojaString {
602    match profile_handle(profile) {
603        Some(profile) => match &profile.inner.description {
604            Some(description) => PamojaString::into_raw(description.clone()),
605            None => ptr::null_mut(),
606        },
607        None => ptr::null_mut(),
608    }
609}
610
611/// Returns a copy of a profile carrying a description of what it is for.
612///
613/// # Arguments
614///
615/// * `profile` - the profile.
616/// * `description` - the description, as null-terminated UTF-8.
617///
618/// # Returns
619///
620/// A handle the caller must release with [`pamoja_profile_free`], or null if
621/// either pointer is null or the text is not UTF-8.
622///
623/// # Safety
624///
625/// `profile` must be a live handle from a call that produced one, and
626/// `description` must be a valid null-terminated UTF-8 string for the duration
627/// of the call; either may be null.
628#[no_mangle]
629pub unsafe extern "C" fn pamoja_profile_with_description(
630    profile: *const PamojaProfile,
631    description: *const c_char,
632) -> *mut PamojaProfile {
633    let Some(profile) = profile_handle(profile) else {
634        return ptr::null_mut();
635    };
636    let Some(description) = read_str(description, "description") else {
637        return ptr::null_mut();
638    };
639    PamojaProfile::into_raw(profile.inner.clone().with_description(description))
640}
641
642/// Returns the quantity a profile says it reads, such as `temperature`.
643///
644/// # Arguments
645///
646/// * `profile` - the profile.
647///
648/// # Returns
649///
650/// A null-terminated UTF-8 string, which the caller must release with
651/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the profile does not
652/// say what it reads or `profile` is null. The two cases are told apart by
653/// [`pamoja_last_error_message`](crate::pamoja_last_error_message), which is set only
654/// for a null handle.
655///
656/// # Safety
657///
658/// `profile` must be a live handle from a call that produced one, or null.
659#[no_mangle]
660pub unsafe extern "C" fn pamoja_profile_reads_quantity(
661    profile: *const PamojaProfile,
662) -> *mut PamojaString {
663    match profile_handle(profile).and_then(|profile| profile.inner.reads.as_ref()) {
664        Some(reads) => PamojaString::into_raw(reads.quantity.clone()),
665        None => ptr::null_mut(),
666    }
667}
668
669/// Returns the unit a profile's numbers are in, such as `celsius`.
670///
671/// # Arguments
672///
673/// * `profile` - the profile.
674///
675/// # Returns
676///
677/// A null-terminated UTF-8 string, which the caller must release with
678/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the profile does not
679/// say what it reads or `profile` is null. The two cases are told apart by
680/// [`pamoja_last_error_message`](crate::pamoja_last_error_message), which is set only
681/// for a null handle.
682///
683/// # Safety
684///
685/// `profile` must be a live handle from a call that produced one, or null.
686#[no_mangle]
687pub unsafe extern "C" fn pamoja_profile_reads_unit(
688    profile: *const PamojaProfile,
689) -> *mut PamojaString {
690    match profile_handle(profile).and_then(|profile| profile.inner.reads.as_ref()) {
691        Some(reads) => PamojaString::into_raw(reads.unit.clone()),
692        None => ptr::null_mut(),
693    }
694}
695
696/// Returns a copy of a profile that says what it reads.
697///
698/// # Arguments
699///
700/// * `profile` - the profile.
701/// * `quantity` - the quantity its control decides on, such as `temperature`, as
702///   null-terminated UTF-8.
703/// * `unit` - the unit its numbers are in, such as `celsius`, as null-terminated UTF-8.
704///
705/// # Returns
706///
707/// A handle the caller must release with [`pamoja_profile_free`], or null if a pointer
708/// is null, a string is not UTF-8, or the quantity or unit is not lowercase words joined
709/// by underscores, with the reason available from
710/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
711///
712/// # Safety
713///
714/// `profile` must be a live handle from a call that produced one, and `quantity` and
715/// `unit` must be valid null-terminated UTF-8 strings for the duration of the call; any
716/// may be null.
717#[no_mangle]
718pub unsafe extern "C" fn pamoja_profile_with_reads(
719    profile: *const PamojaProfile,
720    quantity: *const c_char,
721    unit: *const c_char,
722) -> *mut PamojaProfile {
723    let Some(profile) = profile_handle(profile) else {
724        return ptr::null_mut();
725    };
726    let (Some(quantity), Some(unit)) = (read_str(quantity, "quantity"), read_str(unit, "unit"))
727    else {
728        return ptr::null_mut();
729    };
730    let read = profile.inner.clone().with_reads(quantity, unit);
731    match read.check() {
732        Ok(()) => PamojaProfile::into_raw(read),
733        Err(error) => {
734            set_last_error(error.to_string());
735            ptr::null_mut()
736        }
737    }
738}
739
740/// Returns how a profile presents itself on a dashboard, as the JSON object its
741/// manifest carries under `presentation`.
742///
743/// # Arguments
744///
745/// * `profile` - the profile.
746///
747/// # Returns
748///
749/// A null-terminated UTF-8 string, which the caller must release with
750/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the profile
751/// declares no presentation or `profile` is null. The two cases are told apart
752/// by [`pamoja_last_error_message`](crate::pamoja_last_error_message), which is
753/// set only for a null handle or a presentation that cannot be serialized.
754///
755/// # Safety
756///
757/// `profile` must be a live handle from a call that produced one, or null.
758#[no_mangle]
759pub unsafe extern "C" fn pamoja_profile_presentation_json(
760    profile: *const PamojaProfile,
761) -> *mut PamojaString {
762    let Some(profile) = profile_handle(profile) else {
763        return ptr::null_mut();
764    };
765    let Some(presentation) = &profile.inner.presentation else {
766        return ptr::null_mut();
767    };
768    match presentation.to_json() {
769        Ok(json) => PamojaString::into_raw(json),
770        Err(error) => {
771            set_last_error(error.to_string());
772            ptr::null_mut()
773        }
774    }
775}
776
777/// Returns a copy of a profile carrying a dashboard presentation.
778///
779/// # Arguments
780///
781/// * `profile` - the profile.
782/// * `presentation` - the JSON object a manifest carries under `presentation`,
783///   as null-terminated UTF-8.
784///
785/// # Returns
786///
787/// A handle the caller must release with [`pamoja_profile_free`], or null if
788/// either pointer is null, the JSON is not a presentation, or the presentation holds
789/// something the dashboard could not draw, such as a band whose low end comes second,
790/// with the reason available from
791/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
792///
793/// # Safety
794///
795/// `profile` must be a live handle from a call that produced one, and
796/// `presentation` must be a valid null-terminated UTF-8 string for the duration
797/// of the call; either may be null.
798#[no_mangle]
799pub unsafe extern "C" fn pamoja_profile_with_presentation_json(
800    profile: *const PamojaProfile,
801    presentation: *const c_char,
802) -> *mut PamojaProfile {
803    let Some(profile) = profile_handle(profile) else {
804        return ptr::null_mut();
805    };
806    let Some(presentation) = read_str(presentation, "presentation") else {
807        return ptr::null_mut();
808    };
809    match Presentation::from_json(presentation) {
810        Ok(presentation) => checked(profile.inner.clone().with_presentation(presentation)),
811        Err(error) => {
812            set_last_error(error.to_string());
813            ptr::null_mut()
814        }
815    }
816}
817
818/// Returns the control kind a profile names, as its manifest writes it.
819///
820/// # Arguments
821///
822/// * `profile` - the profile.
823///
824/// # Returns
825///
826/// `setpoint`, `level`, `surge`, `monitor`, or a custom kind's own name, as a
827/// null-terminated UTF-8 string the caller must release with
828/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `profile` is null.
829///
830/// # Safety
831///
832/// `profile` must be a live handle from a call that produced one, or null.
833#[no_mangle]
834pub unsafe extern "C" fn pamoja_profile_control_kind(
835    profile: *const PamojaProfile,
836) -> *mut PamojaString {
837    match profile_handle(profile) {
838        Some(profile) => PamojaString::into_raw(profile.inner.control.kind().to_owned()),
839        None => ptr::null_mut(),
840    }
841}
842
843/// Returns the parameters a custom control kind carries, as the JSON object of
844/// every field the manifest wrote beside `kind`.
845///
846/// # Arguments
847///
848/// * `profile` - the profile.
849///
850/// # Returns
851///
852/// A null-terminated UTF-8 string the caller must release with
853/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the kind is a
854/// built-in one or `profile` is null. The two cases are told apart by
855/// [`pamoja_last_error_message`](crate::pamoja_last_error_message), which is set
856/// only for a null handle or parameters that cannot be serialized.
857///
858/// # Safety
859///
860/// `profile` must be a live handle from a call that produced one, or null.
861#[no_mangle]
862pub unsafe extern "C" fn pamoja_profile_control_params_json(
863    profile: *const PamojaProfile,
864) -> *mut PamojaString {
865    let Some(profile) = profile_handle(profile) else {
866        return ptr::null_mut();
867    };
868    let ControlSpec::Custom { params, .. } = &profile.inner.control else {
869        return ptr::null_mut();
870    };
871    match params.to_json() {
872        Ok(json) => PamojaString::into_raw(json),
873        Err(error) => {
874            set_last_error(error.to_string());
875            ptr::null_mut()
876        }
877    }
878}
879
880/// Returns the control policy a profile applies.
881///
882/// # Arguments
883///
884/// * `profile` - the profile.
885/// * `out_control` - receives the policy.
886///
887/// # Returns
888///
889/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
890/// either pointer is null.
891///
892/// # Safety
893///
894/// `profile` must be a live handle from a call that produced one, and
895/// `out_control` must be writable.
896#[no_mangle]
897pub unsafe extern "C" fn pamoja_profile_control(
898    profile: *const PamojaProfile,
899    out_control: *mut PamojaControlSpec,
900) -> PamojaStatus {
901    if out_control.is_null() {
902        set_last_error("out_control must not be null".to_owned());
903        return PamojaStatus::InvalidArgument;
904    }
905    let Some(profile) = profile_handle(profile) else {
906        return PamojaStatus::InvalidArgument;
907    };
908    *out_control = PamojaControlSpec::from(&profile.inner.control);
909    PamojaStatus::Ok
910}
911
912/// Returns the sampling schedule a profile keeps as its battery drains.
913///
914/// # Arguments
915///
916/// * `profile` - the profile.
917/// * `out_schedule` - receives the schedule.
918///
919/// # Returns
920///
921/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
922/// either pointer is null.
923///
924/// # Safety
925///
926/// `profile` must be a live handle from a call that produced one, and
927/// `out_schedule` must be writable.
928#[no_mangle]
929pub unsafe extern "C" fn pamoja_profile_power(
930    profile: *const PamojaProfile,
931    out_schedule: *mut PamojaPowerSchedule,
932) -> PamojaStatus {
933    if out_schedule.is_null() {
934        set_last_error("out_schedule must not be null".to_owned());
935        return PamojaStatus::InvalidArgument;
936    }
937    let Some(profile) = profile_handle(profile) else {
938        return PamojaStatus::InvalidArgument;
939    };
940    *out_schedule = profile.inner.power.into();
941    PamojaStatus::Ok
942}
943
944/// Assembles a profile's schedule into a power governor.
945///
946/// The governor is the same one [`pamoja_power_plan_new`](crate::power::pamoja_power_plan_new)
947/// builds, so the mode and interval calls in that module apply to it unchanged.
948///
949/// # Arguments
950///
951/// * `profile` - the profile.
952/// * `out_plan` - receives the governor.
953///
954/// # Returns
955///
956/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
957/// either pointer is null.
958///
959/// # Safety
960///
961/// `profile` must be a live handle from a call that produced one, and `out_plan`
962/// must be writable.
963#[no_mangle]
964pub unsafe extern "C" fn pamoja_profile_power_plan(
965    profile: *const PamojaProfile,
966    out_plan: *mut PamojaPowerPlan,
967) -> PamojaStatus {
968    if out_plan.is_null() {
969        set_last_error("out_plan must not be null".to_owned());
970        return PamojaStatus::InvalidArgument;
971    }
972    let Some(profile) = profile_handle(profile) else {
973        return PamojaStatus::InvalidArgument;
974    };
975    let schedule = profile.inner.power;
976    *out_plan = PamojaPowerPlan {
977        active_us: schedule.active_secs.saturating_mul(1_000_000),
978        saver_us: schedule.saver_secs.saturating_mul(1_000_000),
979        critical_us: schedule.critical_secs.saturating_mul(1_000_000),
980        saver_below: schedule.saver_below,
981        critical_below: schedule.critical_below,
982        hysteresis: schedule.hysteresis,
983    };
984    PamojaStatus::Ok
985}
986
987/// Builds the decision logic a profile describes.
988///
989/// # Arguments
990///
991/// * `profile` - the profile.
992///
993/// # Returns
994///
995/// A handle the caller must release with [`pamoja_controller_free`], or null if
996/// `profile` is null or names a custom control kind, which no built-in controller
997/// decides, with the reason available from
998/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
999///
1000/// # Safety
1001///
1002/// `profile` must be a live handle from a call that produced one, or null.
1003#[no_mangle]
1004pub unsafe extern "C" fn pamoja_profile_controller(
1005    profile: *const PamojaProfile,
1006) -> *mut PamojaController {
1007    let Some(profile) = profile_handle(profile) else {
1008        return ptr::null_mut();
1009    };
1010    match profile.inner.controller() {
1011        Ok(controller) => PamojaController::into_raw(controller),
1012        Err(error) => {
1013            set_last_error(error.to_string());
1014            ptr::null_mut()
1015        }
1016    }
1017}
1018
1019/// Releases a profile handle.
1020///
1021/// Passing null is a no-op.
1022///
1023/// # Safety
1024///
1025/// `profile` must be a handle from a call that produced one and that has not
1026/// already been freed, or null. After this call it must not be used again.
1027#[no_mangle]
1028pub unsafe extern "C" fn pamoja_profile_free(profile: *mut PamojaProfile) {
1029    if !profile.is_null() {
1030        drop(Box::from_raw(profile));
1031    }
1032}
1033
1034/// An opaque handle to a profile's decision logic.
1035///
1036/// A controller carries state between readings, because a level estimate and a
1037/// rate of change both need the previous sample, so evaluate readings through
1038/// one controller in the order they were taken.
1039pub struct PamojaController {
1040    inner: Controller,
1041}
1042
1043impl PamojaController {
1044    /// Wraps a controller in a handle for the caller to own.
1045    fn into_raw(inner: Controller) -> *mut Self {
1046        Box::into_raw(Box::new(Self { inner }))
1047    }
1048}
1049
1050/// Creates a controller that holds a reading near a setpoint.
1051///
1052/// # Arguments
1053///
1054/// * `setpoint` - the target reading.
1055/// * `hysteresis` - half the deadband width, which stops the output chattering.
1056/// * `cooling` - whether the output cools rather than heats.
1057/// * `safe_band` - how far the reading may stray before an alert.
1058///
1059/// # Returns
1060///
1061/// A handle the caller must release with [`pamoja_controller_free`].
1062#[no_mangle]
1063pub extern "C" fn pamoja_controller_setpoint(
1064    setpoint: f32,
1065    hysteresis: f32,
1066    cooling: bool,
1067    safe_band: f32,
1068) -> *mut PamojaController {
1069    PamojaController::into_raw(Controller::setpoint(
1070        setpoint, hysteresis, cooling, safe_band,
1071    ))
1072}
1073
1074/// Creates a controller that warns before a falling level reaches empty.
1075///
1076/// # Arguments
1077///
1078/// * `empty` - the level treated as empty.
1079/// * `warn_within` - warn once empty is this many samples away.
1080///
1081/// # Returns
1082///
1083/// A handle the caller must release with [`pamoja_controller_free`].
1084#[no_mangle]
1085pub extern "C" fn pamoja_controller_level(empty: f32, warn_within: u32) -> *mut PamojaController {
1086    PamojaController::into_raw(Controller::level(empty, warn_within))
1087}
1088
1089/// Creates a controller that warns when a reading changes too fast.
1090///
1091/// # Arguments
1092///
1093/// * `rising` - watch a rapid rise rather than a rapid fall.
1094/// * `limit` - the largest safe change per sample.
1095///
1096/// # Returns
1097///
1098/// A handle the caller must release with [`pamoja_controller_free`].
1099#[no_mangle]
1100pub extern "C" fn pamoja_controller_surge(rising: bool, limit: f32) -> *mut PamojaController {
1101    PamojaController::into_raw(Controller::surge(rising, limit))
1102}
1103
1104/// Creates a controller that reports readings without judging them.
1105///
1106/// # Returns
1107///
1108/// A handle the caller must release with [`pamoja_controller_free`].
1109#[no_mangle]
1110pub extern "C" fn pamoja_controller_monitor() -> *mut PamojaController {
1111    PamojaController::into_raw(Controller::monitor())
1112}
1113
1114/// Decides what one reading calls for.
1115///
1116/// # Arguments
1117///
1118/// * `controller` - the decision logic.
1119/// * `reading` - the reading to evaluate.
1120/// * `out_reaction` - receives the decision.
1121///
1122/// # Returns
1123///
1124/// [`PamojaStatus::Ok`] on success, or [`PamojaStatus::InvalidArgument`] if
1125/// either pointer is null.
1126///
1127/// # Safety
1128///
1129/// `controller` must be a live handle from a call that produced one, and
1130/// `out_reaction` must be writable.
1131#[no_mangle]
1132pub unsafe extern "C" fn pamoja_controller_evaluate(
1133    controller: *mut PamojaController,
1134    reading: f32,
1135    out_reaction: *mut PamojaReaction,
1136) -> PamojaStatus {
1137    if controller.is_null() {
1138        set_last_error("controller must not be null".to_owned());
1139        return PamojaStatus::InvalidArgument;
1140    }
1141    if out_reaction.is_null() {
1142        set_last_error("out_reaction must not be null".to_owned());
1143        return PamojaStatus::InvalidArgument;
1144    }
1145    let controller = &mut *controller;
1146    *out_reaction = PamojaReaction::flatten(controller.inner.evaluate(reading));
1147    PamojaStatus::Ok
1148}
1149
1150/// Releases a controller handle.
1151///
1152/// Passing null is a no-op.
1153///
1154/// # Safety
1155///
1156/// `controller` must be a handle from a call that produced one and that has not
1157/// already been freed, or null. After this call it must not be used again.
1158#[no_mangle]
1159pub unsafe extern "C" fn pamoja_controller_free(controller: *mut PamojaController) {
1160    if !controller.is_null() {
1161        drop(Box::from_raw(controller));
1162    }
1163}
1164
1165/// Borrows a profile handle, rejecting a null pointer.
1166///
1167/// # Safety
1168///
1169/// `profile` must be a live handle from a call that produced one, or null.
1170unsafe fn profile_handle<'a>(profile: *const PamojaProfile) -> Option<&'a PamojaProfile> {
1171    if profile.is_null() {
1172        set_last_error("profile must not be null".to_owned());
1173        return None;
1174    }
1175    Some(&*profile)
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use std::ffi::{CStr, CString};
1181
1182    use super::*;
1183
1184    fn text_of(string: *mut PamojaString) -> String {
1185        assert!(!string.is_null(), "the call produced no string");
1186        let text = unsafe { CStr::from_ptr(crate::pamoja_string_data(string)) }
1187            .to_str()
1188            .expect("utf-8")
1189            .to_owned();
1190        unsafe { crate::pamoja_string_free(string) };
1191        text
1192    }
1193
1194    fn reaction_for(controller: *mut PamojaController, reading: f32) -> PamojaReaction {
1195        let mut reaction = PamojaReaction {
1196            has_actuator: false,
1197            actuator: false,
1198            alert: PamojaAlertKind::None,
1199            reading: 0.0,
1200            samples: 0,
1201            rate: 0.0,
1202            code: [0; PAMOJA_ALERT_CODE_LEN],
1203            value: 0.0,
1204        };
1205        assert_eq!(
1206            unsafe { pamoja_controller_evaluate(controller, reading, &mut reaction) },
1207            PamojaStatus::Ok
1208        );
1209        reaction
1210    }
1211
1212    #[test]
1213    fn a_warm_fridge_runs_the_cooler_and_flags_the_excursion() {
1214        let profile = pamoja_profile_vaccine_fridge_monitor();
1215        let controller = unsafe { pamoja_profile_controller(profile) };
1216        let reaction = reaction_for(controller, 9.0);
1217
1218        assert!(reaction.has_actuator, "the profile drives a cooler");
1219        assert!(reaction.actuator, "which runs when the fridge is warm");
1220        assert_eq!(
1221            reaction.alert,
1222            PamojaAlertKind::OutOfRange,
1223            "and 9 C is outside the safe band"
1224        );
1225        assert_eq!(reaction.reading, 9.0);
1226
1227        unsafe {
1228            pamoja_controller_free(controller);
1229            pamoja_profile_free(profile);
1230        }
1231    }
1232
1233    #[test]
1234    fn a_monitoring_profile_drives_no_output() {
1235        let controller = pamoja_controller_monitor();
1236        let reaction = reaction_for(controller, 21.5);
1237        assert!(
1238            !reaction.has_actuator,
1239            "a monitor observes rather than acts"
1240        );
1241        assert_eq!(reaction.alert, PamojaAlertKind::None);
1242        unsafe { pamoja_controller_free(controller) };
1243    }
1244
1245    #[test]
1246    fn a_manifest_survives_a_round_trip() {
1247        let profile = pamoja_profile_well_level();
1248        let manifest = text_of(unsafe { pamoja_profile_to_json(profile) });
1249
1250        let text = CString::new(manifest).expect("no interior null");
1251        let loaded = unsafe { pamoja_profile_from_json(text.as_ptr()) };
1252        assert!(!loaded.is_null());
1253
1254        assert_eq!(
1255            text_of(unsafe { pamoja_profile_name(loaded) }),
1256            text_of(unsafe { pamoja_profile_name(profile) })
1257        );
1258
1259        let mut original = PamojaControlSpec::from(&ControlSpec::Monitor);
1260        let mut restored = original;
1261        unsafe {
1262            assert_eq!(
1263                pamoja_profile_control(profile, &mut original),
1264                PamojaStatus::Ok
1265            );
1266            assert_eq!(
1267                pamoja_profile_control(loaded, &mut restored),
1268                PamojaStatus::Ok
1269            );
1270            pamoja_profile_free(loaded);
1271            pamoja_profile_free(profile);
1272        }
1273        assert_eq!(restored.kind, PamojaControlKind::Level);
1274        assert_eq!(restored, original, "the policy came back unchanged");
1275    }
1276
1277    fn last_error() -> String {
1278        unsafe { CStr::from_ptr(crate::pamoja_last_error_message()) }
1279            .to_string_lossy()
1280            .into_owned()
1281    }
1282
1283    fn drip_band() -> PamojaControlSpec {
1284        PamojaControlSpec::from(&ControlSpec::Setpoint {
1285            setpoint: 37.5,
1286            hysteresis: 7.5,
1287            cooling: false,
1288            safe_band: 15.0,
1289        })
1290    }
1291
1292    #[test]
1293    fn a_profile_no_node_could_run_is_refused_with_the_reason() {
1294        let name = CString::new("raised-bed-drip").unwrap();
1295        let topic = CString::new("garden/bed-1/moisture").unwrap();
1296        let backwards = PamojaPowerSchedule::from(PowerSchedule::new(300, 60, 3600));
1297        // Safety: every pointer is valid for the call.
1298        let profile =
1299            unsafe { pamoja_profile_new(name.as_ptr(), topic.as_ptr(), &drip_band(), &backwards) };
1300        assert!(profile.is_null());
1301        assert!(last_error().contains("must not shorten"));
1302
1303        let schedule = PamojaPowerSchedule::from(PowerSchedule::new(300, 1800, 3600));
1304        let mut chattering = drip_band();
1305        chattering.hysteresis = 0.0;
1306        // Safety: every pointer is valid for the call.
1307        let profile =
1308            unsafe { pamoja_profile_new(name.as_ptr(), topic.as_ptr(), &chattering, &schedule) };
1309        assert!(profile.is_null());
1310        assert!(last_error().contains("chatters"));
1311
1312        let upside_down = CString::new(
1313            r#"{ "elements": [{ "key": "soil", "unit": "percent", "label": "Soil", "viz": "droplet", "band": [60.0, 10.0] }] }"#,
1314        )
1315        .unwrap();
1316        // Safety: the handle is released here and the strings are valid.
1317        unsafe {
1318            let drip = pamoja_profile_irrigation_node();
1319            assert!(pamoja_profile_with_presentation_json(drip, upside_down.as_ptr()).is_null());
1320            assert!(last_error().contains("the low end comes first"));
1321            pamoja_profile_free(drip);
1322        }
1323    }
1324
1325    #[test]
1326    fn a_reading_that_is_not_a_number_crosses_as_its_own_alert() {
1327        let flat = PamojaReaction::flatten(pamoja_profile::Reaction {
1328            actuator: Some(true),
1329            alert: Some(Alert::InvalidReading { reading: f32::NAN }),
1330        });
1331        assert_eq!(flat.alert, PamojaAlertKind::InvalidReading);
1332        assert!(flat.reading.is_nan());
1333        assert!(flat.has_actuator && flat.actuator);
1334    }
1335
1336    #[test]
1337    fn a_profile_built_from_parts_matches_the_one_rust_builds() {
1338        let name = CString::new("raised-bed-drip").unwrap();
1339        let topic = CString::new("garden/bed-1/moisture").unwrap();
1340        let schedule = PamojaPowerSchedule::from(PowerSchedule::new(300, 1800, 3600));
1341        let profile =
1342            unsafe { pamoja_profile_new(name.as_ptr(), topic.as_ptr(), &drip_band(), &schedule) };
1343        assert!(!profile.is_null());
1344
1345        let expected = Profile::new(
1346            "raised-bed-drip",
1347            "garden/bed-1/moisture",
1348            ControlSpec::Setpoint {
1349                setpoint: 37.5,
1350                hysteresis: 7.5,
1351                cooling: false,
1352                safe_band: 15.0,
1353            },
1354            PowerSchedule::new(300, 1800, 3600),
1355        );
1356        assert_eq!(
1357            text_of(unsafe { pamoja_profile_to_json(profile) }),
1358            expected.to_json().unwrap()
1359        );
1360
1361        let controller = unsafe { pamoja_profile_controller(profile) };
1362        let dry = reaction_for(controller, 16.7);
1363        assert!(dry.actuator, "a dry bed opens the valve");
1364        assert_eq!(dry.alert, PamojaAlertKind::OutOfRange);
1365        unsafe {
1366            pamoja_controller_free(controller);
1367            pamoja_profile_free(profile);
1368        }
1369    }
1370
1371    #[test]
1372    fn a_custom_or_unknown_kind_is_refused_by_the_constructor() {
1373        let name = CString::new("orchard-frost").unwrap();
1374        let topic = CString::new("orchard/air").unwrap();
1375        let schedule = PamojaPowerSchedule::from(PowerSchedule::new(60, 300, 900));
1376        let mut custom = drip_band();
1377        custom.kind = PamojaControlKind::Custom;
1378        let refused =
1379            unsafe { pamoja_profile_new(name.as_ptr(), topic.as_ptr(), &custom, &schedule) };
1380        assert!(refused.is_null());
1381        assert!(last_error().contains("pamoja_profile_new_custom"));
1382
1383        const WORDS: usize = std::mem::size_of::<PamojaControlSpec>() / 4;
1384        let mut unknown = [0u32; WORDS];
1385        unknown[0] = 9;
1386        let unknown = unknown.as_ptr().cast::<PamojaControlSpec>();
1387        let refused =
1388            unsafe { pamoja_profile_new(name.as_ptr(), topic.as_ptr(), unknown, &schedule) };
1389        assert!(refused.is_null());
1390        assert!(last_error().contains("9 is not a control kind"));
1391
1392        let refused =
1393            unsafe { pamoja_profile_new(name.as_ptr(), topic.as_ptr(), ptr::null(), &schedule) };
1394        assert!(refused.is_null());
1395    }
1396
1397    #[test]
1398    fn a_custom_kind_is_built_from_its_parameters_and_checked() {
1399        let name = CString::new("orchard-frost").unwrap();
1400        let topic = CString::new("orchard/air").unwrap();
1401        let schedule = PamojaPowerSchedule::from(PowerSchedule::new(60, 300, 900));
1402        let build = |kind: &str, params: &str| {
1403            let kind = CString::new(kind).unwrap();
1404            let params = CString::new(params).unwrap();
1405            unsafe {
1406                pamoja_profile_new_custom(
1407                    name.as_ptr(),
1408                    topic.as_ptr(),
1409                    kind.as_ptr(),
1410                    params.as_ptr(),
1411                    &schedule,
1412                )
1413            }
1414        };
1415
1416        let guard = build("frost_guard", r#"{ "warn_below": 2.0, "zone": "north" }"#);
1417        assert!(!guard.is_null());
1418        assert_eq!(
1419            text_of(unsafe { pamoja_profile_control_kind(guard) }),
1420            "frost_guard"
1421        );
1422        assert_eq!(
1423            text_of(unsafe { pamoja_profile_control_params_json(guard) }),
1424            r#"{"warn_below":2.0,"zone":"north"}"#
1425        );
1426        unsafe { pamoja_profile_free(guard) };
1427
1428        assert!(build("setpoint", "{}").is_null());
1429        assert!(last_error().contains("built-in control kind"));
1430        assert!(build("frost_guard", r#"{ "kind": "x" }"#).is_null());
1431        assert!(last_error().contains("parameter named kind"));
1432        assert!(build("frost_guard", "[1, 2]").is_null());
1433    }
1434
1435    #[test]
1436    fn a_custom_kind_crosses_as_its_name_and_parameters() {
1437        let manifest = CString::new(
1438            r#"{ "name": "orchard-frost", "topic": "orchard/air", "control": { "kind": "frost_guard", "warn_below": 2.0, "zone": "north" }, "power": { "active_secs": 60, "saver_secs": 300, "critical_secs": 900 } }"#,
1439        )
1440        .unwrap();
1441        let profile = unsafe { pamoja_profile_from_json(manifest.as_ptr()) };
1442        assert!(!profile.is_null());
1443        let mut control = PamojaControlSpec::from(&ControlSpec::Monitor);
1444        assert_eq!(
1445            unsafe { pamoja_profile_control(profile, &mut control) },
1446            PamojaStatus::Ok
1447        );
1448        assert_eq!(control.kind, PamojaControlKind::Custom);
1449        assert_eq!(
1450            text_of(unsafe { pamoja_profile_control_kind(profile) }),
1451            "frost_guard"
1452        );
1453        assert_eq!(
1454            text_of(unsafe { pamoja_profile_control_params_json(profile) }),
1455            r#"{"warn_below":2.0,"zone":"north"}"#
1456        );
1457
1458        let fridge = pamoja_profile_vaccine_fridge_monitor();
1459        assert_eq!(
1460            text_of(unsafe { pamoja_profile_control_kind(fridge) }),
1461            "setpoint"
1462        );
1463        assert!(
1464            unsafe { pamoja_profile_control_params_json(fridge) }.is_null(),
1465            "a built-in kind has no parameter object"
1466        );
1467
1468        // No built-in controller decides a custom kind, so asking for one is refused with
1469        // the kind named; the custom alert a host's own policy raises crosses as its code
1470        // and value.
1471        assert!(unsafe { pamoja_profile_controller(profile) }.is_null());
1472        assert!(
1473            last_error().contains("no policy decides the control kind `frost_guard`"),
1474            "{}",
1475            last_error()
1476        );
1477        let flat = PamojaReaction::flatten(pamoja_profile::Reaction {
1478            actuator: Some(true),
1479            alert: Some(Alert::Custom {
1480                code: "FrostRisk",
1481                value: -4.0,
1482            }),
1483        });
1484        assert_eq!(flat.alert, PamojaAlertKind::Custom);
1485        assert_eq!(flat.value, -4.0);
1486        let code = unsafe { CStr::from_ptr(flat.code.as_ptr()) }
1487            .to_str()
1488            .unwrap();
1489        assert_eq!(code, "FrostRisk");
1490        let long = PamojaReaction::flatten(pamoja_profile::Reaction {
1491            actuator: None,
1492            alert: Some(Alert::Custom {
1493                code: "a-code-far-longer-than-thirty-one-bytes-will-allow",
1494                value: 0.0,
1495            }),
1496        });
1497        let cut = unsafe { CStr::from_ptr(long.code.as_ptr()) }
1498            .to_str()
1499            .unwrap();
1500        assert_eq!(cut.len(), PAMOJA_ALERT_CODE_LEN - 1);
1501
1502        unsafe {
1503            pamoja_profile_free(fridge);
1504            pamoja_profile_free(profile);
1505        }
1506    }
1507
1508    #[test]
1509    fn what_a_profile_reads_crosses_as_its_quantity_and_unit() {
1510        let fridge = pamoja_profile_vaccine_fridge_monitor();
1511        assert_eq!(
1512            text_of(unsafe { pamoja_profile_reads_quantity(fridge) }),
1513            "temperature"
1514        );
1515        assert_eq!(
1516            text_of(unsafe { pamoja_profile_reads_unit(fridge) }),
1517            "celsius"
1518        );
1519        let quantity = CString::new("relative_humidity").unwrap();
1520        let unit = CString::new("percent").unwrap();
1521        let humid = unsafe { pamoja_profile_with_reads(fridge, quantity.as_ptr(), unit.as_ptr()) };
1522        assert!(!humid.is_null());
1523        assert_eq!(
1524            text_of(unsafe { pamoja_profile_reads_quantity(humid) }),
1525            "relative_humidity"
1526        );
1527        let shouting = CString::new("Temperature").unwrap();
1528        assert!(
1529            unsafe { pamoja_profile_with_reads(fridge, shouting.as_ptr(), unit.as_ptr()) }
1530                .is_null()
1531        );
1532        assert!(last_error().contains("lowercase words"), "{}", last_error());
1533
1534        let manifest = CString::new(
1535            r#"{ "name": "tank", "topic": "water/tank", "control": { "kind": "monitor" }, "power": { "active_secs": 60, "saver_secs": 300, "critical_secs": 900 } }"#,
1536        )
1537        .unwrap();
1538        let unread = unsafe { pamoja_profile_from_json(manifest.as_ptr()) };
1539        assert!(unsafe { pamoja_profile_reads_quantity(unread) }.is_null());
1540        assert!(unsafe { pamoja_profile_reads_unit(unread) }.is_null());
1541        unsafe {
1542            pamoja_profile_free(unread);
1543            pamoja_profile_free(humid);
1544            pamoja_profile_free(fridge);
1545        }
1546    }
1547
1548    #[test]
1549    fn a_description_and_a_presentation_cross_as_text() {
1550        let profile = pamoja_profile_well_level();
1551        let description = text_of(unsafe { pamoja_profile_description(profile) });
1552        assert!(description.contains("runs dry"), "{description}");
1553        let shipped = text_of(unsafe { pamoja_profile_presentation_json(profile) });
1554        assert!(shipped.contains("well_level"), "{shipped}");
1555
1556        let presentation = CString::new(
1557            r#"{ "elements": [ { "key": "water_turbidity", "unit": "ntu", "label": "Turbidity", "viz": "gauge", "band": [0.0, 5.0] } ], "messages": { "state.flushing": "Flushing" } }"#,
1558        )
1559        .unwrap();
1560        let drawn =
1561            unsafe { pamoja_profile_with_presentation_json(profile, presentation.as_ptr()) };
1562        assert!(!drawn.is_null());
1563        let json = text_of(unsafe { pamoja_profile_presentation_json(drawn) });
1564        assert!(json.contains("\"viz\": \"gauge\""), "{json}");
1565        assert!(
1566            text_of(unsafe { pamoja_profile_to_json(drawn) }).contains("water_turbidity"),
1567            "the manifest carries it"
1568        );
1569
1570        let described = CString::new("Warns before the village borehole runs dry.").unwrap();
1571        let renamed = unsafe { pamoja_profile_with_description(drawn, described.as_ptr()) };
1572        assert_eq!(
1573            text_of(unsafe { pamoja_profile_description(renamed) }),
1574            "Warns before the village borehole runs dry."
1575        );
1576
1577        let malformed = CString::new("{ \"elements\": 3 }").unwrap();
1578        assert!(
1579            unsafe { pamoja_profile_with_presentation_json(profile, malformed.as_ptr()) }.is_null()
1580        );
1581        assert!(unsafe { pamoja_profile_with_presentation_json(profile, ptr::null()) }.is_null());
1582
1583        unsafe {
1584            pamoja_profile_free(renamed);
1585            pamoja_profile_free(drawn);
1586            pamoja_profile_free(profile);
1587        }
1588    }
1589
1590    #[test]
1591    fn a_schedule_becomes_a_governor_in_microseconds() {
1592        let profile = pamoja_profile_flood_sensor();
1593        let mut schedule = PamojaPowerSchedule {
1594            active_secs: 0,
1595            saver_secs: 0,
1596            critical_secs: 0,
1597            saver_below: 0.0,
1598            critical_below: 0.0,
1599            hysteresis: 0.0,
1600        };
1601        let mut plan = PamojaPowerPlan {
1602            active_us: 0,
1603            saver_us: 0,
1604            critical_us: 0,
1605            saver_below: 0.0,
1606            critical_below: 0.0,
1607            hysteresis: 0.0,
1608        };
1609        unsafe {
1610            assert_eq!(
1611                pamoja_profile_power(profile, &mut schedule),
1612                PamojaStatus::Ok
1613            );
1614            assert_eq!(
1615                pamoja_profile_power_plan(profile, &mut plan),
1616                PamojaStatus::Ok
1617            );
1618            pamoja_profile_free(profile);
1619        }
1620        assert_eq!(plan.active_us, schedule.active_secs * 1_000_000);
1621        assert_eq!(plan.saver_below, schedule.saver_below);
1622        assert_eq!(plan.hysteresis, schedule.hysteresis);
1623        assert_eq!(schedule.hysteresis, pamoja_power::DEFAULT_HYSTERESIS);
1624    }
1625
1626    #[test]
1627    fn a_null_argument_is_rejected_rather_than_dereferenced() {
1628        assert!(unsafe { pamoja_profile_from_json(ptr::null()) }.is_null());
1629        assert!(unsafe { pamoja_profile_to_json(ptr::null()) }.is_null());
1630        assert!(unsafe { pamoja_profile_name(ptr::null()) }.is_null());
1631        assert!(unsafe { pamoja_profile_topic(ptr::null()) }.is_null());
1632        assert!(unsafe { pamoja_profile_controller(ptr::null()) }.is_null());
1633        assert_eq!(
1634            unsafe { pamoja_profile_control(ptr::null(), ptr::null_mut()) },
1635            PamojaStatus::InvalidArgument
1636        );
1637        assert_eq!(
1638            unsafe { pamoja_controller_evaluate(ptr::null_mut(), 0.0, ptr::null_mut()) },
1639            PamojaStatus::InvalidArgument
1640        );
1641    }
1642}