1use 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#[repr(C)]
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum PamojaControlKind {
40 Setpoint = 0,
42 Level = 1,
44 Surge = 2,
46 Monitor = 3,
48 Custom = 4,
52}
53
54pub const PAMOJA_ALERT_CODE_LEN: usize = 32;
57
58#[repr(C)]
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct PamojaControlSpec {
64 pub kind: PamojaControlKind,
66 pub setpoint: f32,
68 pub hysteresis: f32,
70 pub cooling: bool,
73 pub safe_band: f32,
76 pub empty: f32,
78 pub warn_within: u32,
80 pub rising: bool,
83 pub limit: f32,
85}
86
87#[repr(C)]
89#[derive(Clone, Copy, Debug, PartialEq)]
90pub struct PamojaPowerSchedule {
91 pub active_secs: u64,
93 pub saver_secs: u64,
95 pub critical_secs: u64,
97 pub saver_below: f32,
99 pub critical_below: f32,
101 pub hysteresis: f32,
103}
104
105#[repr(C)]
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum PamojaAlertKind {
109 None = 0,
111 OutOfRange = 1,
113 RunningOut = 2,
115 ChangingFast = 3,
117 Custom = 4,
119 InvalidReading = 5,
121}
122
123#[repr(C)]
127#[derive(Clone, Copy, Debug, PartialEq)]
128pub struct PamojaReaction {
129 pub has_actuator: bool,
134 pub actuator: bool,
136 pub alert: PamojaAlertKind,
138 pub reading: f32,
141 pub samples: u32,
143 pub rate: f32,
146 pub code: [c_char; PAMOJA_ALERT_CODE_LEN],
148 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 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
255pub struct PamojaProfile {
257 inner: Profile,
258}
259
260impl PamojaProfile {
261 fn into_raw(inner: Profile) -> *mut Self {
263 Box::into_raw(Box::new(Self { inner }))
264 }
265}
266
267#[no_mangle]
273pub extern "C" fn pamoja_profile_vaccine_fridge_monitor() -> *mut PamojaProfile {
274 PamojaProfile::into_raw(Profile::vaccine_fridge_monitor())
275}
276
277#[no_mangle]
283pub extern "C" fn pamoja_profile_irrigation_node() -> *mut PamojaProfile {
284 PamojaProfile::into_raw(Profile::irrigation_node())
285}
286
287#[no_mangle]
293pub extern "C" fn pamoja_profile_well_level() -> *mut PamojaProfile {
294 PamojaProfile::into_raw(Profile::well_level())
295}
296
297#[no_mangle]
303pub extern "C" fn pamoja_profile_flood_sensor() -> *mut PamojaProfile {
304 PamojaProfile::into_raw(Profile::flood_sensor())
305}
306
307#[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#[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
388fn 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#[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
453fn 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
465unsafe 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
1034pub struct PamojaController {
1040 inner: Controller,
1041}
1042
1043impl PamojaController {
1044 fn into_raw(inner: Controller) -> *mut Self {
1046 Box::into_raw(Box::new(Self { inner }))
1047 }
1048}
1049
1050#[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#[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#[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#[no_mangle]
1110pub extern "C" fn pamoja_controller_monitor() -> *mut PamojaController {
1111 PamojaController::into_raw(Controller::monitor())
1112}
1113
1114#[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#[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
1165unsafe 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 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 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 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 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}