1use std::collections::{BTreeMap, BTreeSet};
5use std::future::Future;
6use std::pin::Pin;
7
8use pamoja_codec::Codec;
9use pamoja_core::{Actuator, Error, Receive, Result, Transport};
10use pamoja_kit::{Edge, Trigger};
11use serde::{Deserialize, Serialize};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Compare {
17 Above,
19 Below,
21}
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(try_from = "ConditionFile", into = "ConditionFile")]
38pub struct Condition {
39 pub topic: String,
41 pub compare: Compare,
43 pub threshold: f32,
45 pub hysteresis: f32,
47}
48
49#[derive(Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct ConditionFile {
53 topic: String,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 above: Option<f32>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 below: Option<f32>,
58 #[serde(default)]
59 hysteresis: f32,
60}
61
62impl TryFrom<ConditionFile> for Condition {
63 type Error = String;
64
65 fn try_from(file: ConditionFile) -> core::result::Result<Self, String> {
66 let (compare, threshold) = match (file.above, file.below) {
67 (Some(line), None) => (Compare::Above, line),
68 (None, Some(line)) => (Compare::Below, line),
69 (Some(_), Some(_)) => {
70 return Err(format!(
71 "the condition on `{}` names both `above` and `below`; a rule draws one line, so write a second rule for the other",
72 file.topic
73 ))
74 }
75 (None, None) => {
76 return Err(format!(
77 "the condition on `{}` needs a line to cross: `above` or `below`, such as \"below\": 30.0",
78 file.topic
79 ))
80 }
81 };
82 Ok(Condition {
83 topic: file.topic,
84 compare,
85 threshold,
86 hysteresis: file.hysteresis,
87 })
88 }
89}
90
91impl From<Condition> for ConditionFile {
92 fn from(condition: Condition) -> Self {
93 let (above, below) = match condition.compare {
94 Compare::Above => (Some(condition.threshold), None),
95 Compare::Below => (None, Some(condition.threshold)),
96 };
97 ConditionFile {
98 topic: condition.topic,
99 above,
100 below,
101 hysteresis: condition.hysteresis,
102 }
103 }
104}
105
106impl Condition {
107 pub fn above(topic: impl Into<String>, threshold: f32) -> Self {
118 Self {
119 topic: topic.into(),
120 compare: Compare::Above,
121 threshold,
122 hysteresis: 0.0,
123 }
124 }
125
126 pub fn below(topic: impl Into<String>, threshold: f32) -> Self {
137 Self {
138 topic: topic.into(),
139 compare: Compare::Below,
140 threshold,
141 hysteresis: 0.0,
142 }
143 }
144
145 pub fn with_hysteresis(mut self, hysteresis: f32) -> Self {
156 self.hysteresis = hysteresis;
157 self
158 }
159
160 pub fn trigger(&self) -> Trigger {
166 match self.compare {
167 Compare::Above => Trigger::above(self.threshold, self.hysteresis),
168 Compare::Below => Trigger::below(self.threshold, self.hysteresis),
169 }
170 }
171}
172
173#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182#[serde(try_from = "ActionFile", into = "ActionFile")]
183pub enum Action {
184 Drive {
186 actuator: String,
188 on: bool,
190 },
191 Publish {
193 topic: String,
195 payload: String,
197 },
198}
199
200#[derive(Serialize, Deserialize)]
202#[serde(deny_unknown_fields)]
203struct ActionFile {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 drive: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 on: Option<bool>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 publish: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 payload: Option<String>,
212}
213
214impl TryFrom<ActionFile> for Action {
215 type Error = String;
216
217 fn try_from(file: ActionFile) -> core::result::Result<Self, String> {
218 match file {
219 ActionFile {
220 drive: Some(actuator),
221 on: Some(on),
222 publish: None,
223 payload: None,
224 } => Ok(Action::Drive { actuator, on }),
225 ActionFile {
226 drive: None,
227 on: None,
228 publish: Some(topic),
229 payload: Some(payload),
230 } => Ok(Action::Publish { topic, payload }),
231 ActionFile {
232 drive: Some(_),
233 publish: Some(_),
234 ..
235 } => Err("an action does one thing: `drive` or `publish`, not both".to_owned()),
236 ActionFile {
237 drive: Some(actuator),
238 on: None,
239 ..
240 } => Err(format!(
241 "driving `{actuator}` needs `on`: true to switch it on, false to switch it off"
242 )),
243 ActionFile {
244 publish: Some(topic),
245 payload: None,
246 ..
247 } => Err(format!(
248 "publishing to `{topic}` needs a `payload`, the text to send"
249 )),
250 ActionFile { drive: Some(_), .. } => {
251 Err("`payload` belongs to `publish`, not to `drive`".to_owned())
252 }
253 ActionFile {
254 publish: Some(_), ..
255 } => Err("`on` belongs to `drive`, not to `publish`".to_owned()),
256 ActionFile { .. } => Err(
257 "an action names what it does: `drive` an actuator or `publish` to a topic"
258 .to_owned(),
259 ),
260 }
261 }
262}
263
264impl From<Action> for ActionFile {
265 fn from(action: Action) -> Self {
266 match action {
267 Action::Drive { actuator, on } => ActionFile {
268 drive: Some(actuator),
269 on: Some(on),
270 publish: None,
271 payload: None,
272 },
273 Action::Publish { topic, payload } => ActionFile {
274 drive: None,
275 on: None,
276 publish: Some(topic),
277 payload: Some(payload),
278 },
279 }
280 }
281}
282
283#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
298#[serde(deny_unknown_fields)]
299pub struct Rule {
300 pub name: String,
302 pub when: Condition,
304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub then: Vec<Action>,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub otherwise: Vec<Action>,
310}
311
312impl Rule {
313 pub fn new(name: impl Into<String>, when: Condition) -> Self {
324 Self {
325 name: name.into(),
326 when,
327 then: Vec::new(),
328 otherwise: Vec::new(),
329 }
330 }
331
332 pub fn then(mut self, action: Action) -> Self {
342 self.then.push(action);
343 self
344 }
345
346 pub fn otherwise(mut self, action: Action) -> Self {
356 self.otherwise.push(action);
357 self
358 }
359
360 pub fn actuators(&self) -> impl Iterator<Item = &str> {
366 let mut names: BTreeSet<&str> = BTreeSet::new();
367 for action in self.then.iter().chain(&self.otherwise) {
368 if let Action::Drive { actuator, .. } = action {
369 names.insert(actuator);
370 }
371 }
372 names.into_iter()
373 }
374}
375
376#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
386#[serde(try_from = "RulesFile", into = "RulesFile")]
387pub struct Rules {
388 pub rules: Vec<Rule>,
390}
391
392#[derive(Serialize, Deserialize)]
394#[serde(deny_unknown_fields)]
395struct RulesFile {
396 #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
397 schema: Option<String>,
398 rules: Vec<Rule>,
399}
400
401impl TryFrom<RulesFile> for Rules {
402 type Error = String;
403
404 fn try_from(file: RulesFile) -> core::result::Result<Self, String> {
405 crate::format::check_schema(file.schema.as_deref(), "rules", Rules::SCHEMA)?;
406 Ok(Rules { rules: file.rules })
407 }
408}
409
410impl From<Rules> for RulesFile {
411 fn from(rules: Rules) -> Self {
412 RulesFile {
413 schema: Some(Rules::SCHEMA.to_owned()),
414 rules: rules.rules,
415 }
416 }
417}
418
419impl Rules {
420 pub const SCHEMA: &'static str = "https://pamoja.molex.cloud/schema/rules-1.json";
423
424 pub fn new() -> Self {
430 Self::default()
431 }
432
433 pub fn with(mut self, rule: Rule) -> Self {
443 self.rules.push(rule);
444 self
445 }
446
447 #[cfg(feature = "json")]
487 pub fn from_json(text: &str) -> Result<Self> {
488 let rules: Rules = serde_json::from_str(text)
489 .map_err(|error| Error::Codec(crate::format::explain(&error)))?;
490 rules.check()?;
491 Ok(rules)
492 }
493
494 #[cfg(feature = "json")]
504 pub fn to_json(&self) -> Result<String> {
505 serde_json::to_string_pretty(self).map_err(|error| Error::Codec(error.to_string()))
506 }
507
508 pub fn check(&self) -> Result<()> {
537 let refuse = |reason: String| Err(Error::Codec(reason));
538 let filter = |topic: &str| topic.contains(['+', '#']);
539 let mut names = BTreeSet::new();
540 for rule in &self.rules {
541 let name = &rule.name;
542 if name.trim().is_empty() {
543 return refuse("a rule needs a name".to_owned());
544 }
545 if !names.insert(name.as_str()) {
546 return refuse(format!("two rules share the name `{name}`"));
547 }
548 let topic = &rule.when.topic;
549 if topic.trim().is_empty() {
550 return refuse(format!("the rule `{name}` needs a topic to watch"));
551 }
552 if filter(topic) {
553 return refuse(format!(
554 "the rule `{name}` watches `{topic}`, a filter; a rule watches one topic exactly"
555 ));
556 }
557 if !rule.when.threshold.is_finite() {
558 return refuse(format!(
559 "the rule `{name}` needs a threshold that is a finite number, not {}",
560 rule.when.threshold
561 ));
562 }
563 if !(rule.when.hysteresis.is_finite() && rule.when.hysteresis >= 0.0) {
564 return refuse(format!(
565 "the rule `{name}` needs a hysteresis that is a finite number of zero or more, not {}",
566 rule.when.hysteresis
567 ));
568 }
569 if rule.then.is_empty() && rule.otherwise.is_empty() {
570 return refuse(format!("the rule `{name}` has nothing to do"));
571 }
572 for action in rule.then.iter().chain(&rule.otherwise) {
573 match action {
574 Action::Drive { actuator, .. } if actuator.trim().is_empty() => {
575 return refuse(format!(
576 "the rule `{name}` drives an actuator with no name"
577 ));
578 }
579 Action::Publish { topic, .. } if topic.trim().is_empty() => {
580 return refuse(format!("the rule `{name}` publishes to no topic"));
581 }
582 Action::Publish { topic, .. } if filter(topic) => {
583 return refuse(format!(
584 "the rule `{name}` publishes to `{topic}`, a filter rather than a topic"
585 ));
586 }
587 _ => {}
588 }
589 }
590 }
591 Ok(())
592 }
593}
594
595#[derive(Clone, Debug, PartialEq)]
597pub struct Fired {
598 pub rule: String,
600 pub edge: Edge,
602 pub reading: f32,
604 pub actions: Vec<Action>,
607}
608
609pub struct RuleEvaluator {
639 armed: Vec<Armed>,
640}
641
642impl RuleEvaluator {
643 pub fn new(rules: Rules) -> Result<Self> {
657 rules.check()?;
658 Ok(Self::unchecked(rules))
659 }
660
661 fn unchecked(rules: Rules) -> Self {
664 Self {
665 armed: rules
666 .rules
667 .into_iter()
668 .map(|rule| Armed {
669 trigger: rule.when.trigger(),
670 rule,
671 })
672 .collect(),
673 }
674 }
675
676 pub fn evaluate(&mut self, topic: &str, reading: f32) -> Result<Vec<Fired>> {
695 let watching = self.watching(topic);
696 if watching.is_empty() {
697 return Ok(Vec::new());
698 }
699 finite(topic, reading)?;
700 Ok(watching
701 .into_iter()
702 .filter_map(|index| self.judge(index, reading))
703 .collect())
704 }
705
706 pub fn watches(&self, topic: &str) -> bool {
717 self.armed
718 .iter()
719 .any(|armed| armed.rule.when.topic == topic)
720 }
721
722 pub fn topics(&self) -> Vec<&str> {
728 let topics: BTreeSet<&str> = self
729 .armed
730 .iter()
731 .map(|armed| armed.rule.when.topic.as_str())
732 .collect();
733 topics.into_iter().collect()
734 }
735
736 pub fn actuators(&self) -> Vec<&str> {
742 let actuators: BTreeSet<&str> = self
743 .armed
744 .iter()
745 .flat_map(|armed| armed.rule.actuators())
746 .collect();
747 actuators.into_iter().collect()
748 }
749
750 pub fn rules(&self) -> impl Iterator<Item = &Rule> {
756 self.armed.iter().map(|armed| &armed.rule)
757 }
758
759 pub fn is_set(&self, rule: &str) -> Option<bool> {
769 self.armed
770 .iter()
771 .find(|armed| armed.rule.name == rule)
772 .map(|armed| armed.trigger.is_set())
773 }
774
775 fn watching(&self, topic: &str) -> Vec<usize> {
777 self.armed
778 .iter()
779 .enumerate()
780 .filter(|(_, armed)| armed.rule.when.topic == topic)
781 .map(|(index, _)| index)
782 .collect()
783 }
784
785 fn judge(&mut self, index: usize, reading: f32) -> Option<Fired> {
787 let armed = &mut self.armed[index];
788 let edge = armed.trigger.update(reading)?;
789 let actions = match edge {
790 Edge::Set => armed.rule.then.clone(),
791 Edge::Cleared => armed.rule.otherwise.clone(),
792 };
793 Some(Fired {
794 rule: armed.rule.name.clone(),
795 edge,
796 reading,
797 actions,
798 })
799 }
800}
801
802fn finite(topic: &str, reading: f32) -> Result<()> {
804 if reading.is_finite() {
805 Ok(())
806 } else {
807 Err(Error::Codec(format!(
808 "the reading on `{topic}` is {reading}, which is not a finite number"
809 )))
810 }
811}
812
813trait ErasedActuator: Send {
816 fn apply<'a>(&'a mut self, on: bool) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
817}
818
819impl<A: Actuator<Command = bool> + Send> ErasedActuator for A {
820 fn apply<'a>(&'a mut self, on: bool) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
821 Box::pin(Actuator::apply(self, on))
822 }
823}
824
825struct Armed {
827 rule: Rule,
828 trigger: Trigger,
829}
830
831pub struct RuleEngine<L, C> {
882 evaluator: RuleEvaluator,
883 link: L,
884 codec: C,
885 actuators: BTreeMap<String, Box<dyn ErasedActuator>>,
886}
887
888impl<L, C> RuleEngine<L, C> {
889 pub fn new(rules: Rules, link: L, codec: C) -> Self {
902 Self {
903 evaluator: RuleEvaluator::unchecked(rules),
904 link,
905 codec,
906 actuators: BTreeMap::new(),
907 }
908 }
909
910 pub fn with_actuator(
921 mut self,
922 name: impl Into<String>,
923 actuator: impl Actuator<Command = bool> + Send + 'static,
924 ) -> Self {
925 self.actuators.insert(name.into(), Box::new(actuator));
926 self
927 }
928
929 pub fn rules(&self) -> impl Iterator<Item = &Rule> {
935 self.evaluator.rules()
936 }
937
938 pub fn topics(&self) -> Vec<&str> {
944 self.evaluator.topics()
945 }
946
947 pub fn actuators(&self) -> Vec<&str> {
953 self.evaluator.actuators()
954 }
955
956 pub fn is_set(&self, rule: &str) -> Option<bool> {
966 self.evaluator.is_set(rule)
967 }
968}
969
970impl<L, C> RuleEngine<L, C>
971where
972 L: Transport + Receive,
973 C: Codec<f32>,
974{
975 pub async fn connect(&mut self) -> Result<()> {
987 Rules {
988 rules: self.evaluator.rules().cloned().collect(),
989 }
990 .check()?;
991 if self
992 .evaluator
993 .actuators()
994 .iter()
995 .any(|actuator| !self.actuators.contains_key(*actuator))
996 {
997 return Err(Error::Unsupported(
998 "a rule drives an actuator the engine was not given",
999 ));
1000 }
1001 self.link.connect().await?;
1002 for topic in self.evaluator.topics() {
1003 self.link.subscribe(topic).await?;
1004 }
1005 Ok(())
1006 }
1007
1008 pub async fn step(&mut self) -> Result<Option<Vec<Fired>>> {
1021 let Some(message) = self.link.recv().await? else {
1022 return Ok(None);
1023 };
1024 let watching = self.evaluator.watching(&message.topic);
1025 let mut fired = Vec::new();
1026 if watching.is_empty() {
1027 return Ok(Some(fired));
1028 }
1029 let reading: f32 = self.codec.decode(&message.payload)?;
1030 finite(&message.topic, reading)?;
1031 for index in watching {
1032 let Some(one) = self.evaluator.judge(index, reading) else {
1033 continue;
1034 };
1035 for action in &one.actions {
1036 self.run(action).await?;
1037 }
1038 fired.push(one);
1039 }
1040 Ok(Some(fired))
1041 }
1042
1043 async fn run(&mut self, action: &Action) -> Result<()> {
1044 match action {
1045 Action::Drive { actuator, on } => {
1046 let actuator = self.actuators.get_mut(actuator).ok_or(Error::Unsupported(
1047 "a rule drives an actuator the engine was not given",
1048 ))?;
1049 actuator.apply(*on).await
1050 }
1051 Action::Publish { topic, payload } => self.link.send(topic, payload.as_bytes()).await,
1052 }
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use std::sync::{Arc, Mutex};
1059
1060 use pamoja_codec::JsonCodec;
1061 use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
1062
1063 use super::*;
1064
1065 const FILE: &str = r#"{
1066 "$schema": "https://pamoja.molex.cloud/schema/rules-1.json",
1067 "rules": [
1068 {
1069 "name": "water-when-dry",
1070 "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
1071 "then": [
1072 { "drive": "bed-valve", "on": true },
1073 { "publish": "garden/bed-1/valve", "payload": "open" }
1074 ],
1075 "otherwise": [
1076 { "drive": "bed-valve", "on": false },
1077 { "publish": "garden/bed-1/valve", "payload": "closed" }
1078 ]
1079 }
1080 ]
1081}"#;
1082
1083 #[derive(Clone, Default)]
1084 struct Valve(Arc<Mutex<Vec<bool>>>);
1085
1086 impl Actuator for Valve {
1087 type Command = bool;
1088
1089 async fn apply(&mut self, open: bool) -> Result<()> {
1090 self.0.lock().expect("valve lock").push(open);
1091 Ok(())
1092 }
1093 }
1094
1095 #[test]
1096 fn a_rule_file_round_trips_and_builds_its_trigger() {
1097 let rules = Rules::from_json(FILE).expect("parses");
1098 assert_eq!(rules.rules.len(), 1);
1099 let rule = &rules.rules[0];
1100 assert_eq!(rule.name, "water-when-dry");
1101 assert_eq!(rule.when.compare, Compare::Below);
1102 assert_eq!(rule.when.hysteresis, 5.0);
1103 assert_eq!(rule.actuators().collect::<Vec<_>>(), ["bed-valve"]);
1104 let mut trigger = rule.when.trigger();
1105 assert_eq!(trigger.update(28.0), Some(Edge::Set));
1106 let shared = rules.to_json().unwrap();
1107 assert!(
1108 shared.contains("\"publish\": \"garden/bed-1/valve\",\n"),
1109 "{shared}"
1110 );
1111 assert!(
1112 shared.starts_with(
1113 "{\n \"$schema\": \"https://pamoja.molex.cloud/schema/rules-1.json\""
1114 ),
1115 "{shared}"
1116 );
1117 assert_eq!(Rules::from_json(&shared).unwrap(), rules);
1118
1119 let built = Rules::new().with(
1120 Rule::new(
1121 "water-when-dry",
1122 Condition::below("garden/bed-1/moisture", 30.0).with_hysteresis(5.0),
1123 )
1124 .then(Action::Drive {
1125 actuator: "bed-valve".into(),
1126 on: true,
1127 })
1128 .then(Action::Publish {
1129 topic: "garden/bed-1/valve".into(),
1130 payload: "open".into(),
1131 })
1132 .otherwise(Action::Drive {
1133 actuator: "bed-valve".into(),
1134 on: false,
1135 })
1136 .otherwise(Action::Publish {
1137 topic: "garden/bed-1/valve".into(),
1138 payload: "closed".into(),
1139 }),
1140 );
1141 assert_eq!(built, rules);
1142 assert_eq!(Condition::above("t", 1.0).compare, Compare::Above);
1143 }
1144
1145 #[test]
1146 fn a_rule_file_says_what_it_does_in_its_own_words_or_is_refused() {
1147 let rule = |when: &str, then: &str| {
1148 format!(r#"{{ "rules": [ {{ "name": "r", "when": {when}, "then": [ {then} ] }} ] }}"#)
1149 };
1150 let refused = |text: String| match Rules::from_json(&text) {
1151 Err(Error::Codec(reason)) => reason,
1152 other => panic!("expected {text} to be refused, got {other:?}"),
1153 };
1154 let drive = r#"{ "drive": "fan", "on": true }"#;
1155 let when = r#"{ "topic": "t", "above": 1.0 }"#;
1156 Rules::from_json(&rule(when, drive)).expect("a usable rule");
1157
1158 assert!(refused(rule(r#"{ "topic": "t" }"#, drive)).contains("needs a line to cross"));
1159 assert!(refused(rule(
1160 r#"{ "topic": "t", "above": 1.0, "below": 0.0 }"#,
1161 drive
1162 ))
1163 .contains("names both `above` and `below`"));
1164 assert!(refused(rule(
1165 r#"{ "topic": "t", "compare": "above", "threshold": 1.0 }"#,
1166 drive
1167 ))
1168 .starts_with("unknown field `compare`, expected one of `topic`, `above`, `below`"));
1169 assert!(refused(rule(r#"{ "topic": "t", "abve": 1.0 }"#, drive))
1170 .starts_with("unknown field `abve`, did you mean `above`?"));
1171
1172 assert!(refused(rule(when, r#"{ "drive": "fan" }"#)).contains("needs `on`"));
1173 assert!(refused(rule(when, r#"{ "publish": "t/out" }"#)).contains("needs a `payload`"));
1174 assert!(refused(rule(
1175 when,
1176 r#"{ "drive": "fan", "on": true, "publish": "t/out", "payload": "x" }"#
1177 ))
1178 .contains("not both"));
1179 assert!(refused(rule(
1180 when,
1181 r#"{ "drive": "fan", "on": true, "payload": "x" }"#
1182 ))
1183 .contains("`payload` belongs to `publish`"));
1184 assert!(refused(rule(when, r#"{ "on": true }"#)).contains("an action names what it does"));
1185 assert!(refused(rule(
1186 when,
1187 r#"{ "do": "drive", "actuator": "fan", "on": true }"#
1188 ))
1189 .starts_with("unknown field `do`"));
1190
1191 let newer = FILE.replace("rules-1.json", "rules-2.json");
1192 assert!(refused(newer).contains("rules format 2"));
1193 let idle = rule(when, "").replace(r#", "then": [ ]"#, "");
1194 assert_eq!(refused(idle), "the rule `r` has nothing to do");
1195 }
1196
1197 #[test]
1198 fn a_rule_file_is_checked_before_it_runs() {
1199 let ok = Rules::from_json(FILE).unwrap();
1200 ok.check().expect("a usable file");
1201 let publish = || Action::Publish {
1202 topic: "x".into(),
1203 payload: "y".into(),
1204 };
1205 let refused = |rules: Rules| match rules.check() {
1206 Err(Error::Codec(reason)) => reason,
1207 other => panic!("expected a codec error, got {other:?}"),
1208 };
1209
1210 let unnamed = Rules::new().with(Rule::new("", Condition::above("t", 1.0)).then(publish()));
1211 assert_eq!(refused(unnamed), "a rule needs a name");
1212 let idle = Rules::new().with(Rule::new("idle", Condition::above("t", 1.0)));
1213 assert_eq!(refused(idle), "the rule `idle` has nothing to do");
1214 let twice = Rules::new()
1215 .with(Rule::new("a", Condition::above("t", 1.0)).then(publish()))
1216 .with(Rule::new("a", Condition::above("t", 1.0)).then(publish()));
1217 assert_eq!(refused(twice), "two rules share the name `a`");
1218 let negative = Rules::new()
1219 .with(Rule::new("a", Condition::above("t", 1.0).with_hysteresis(-1.0)).then(publish()));
1220 assert!(refused(negative).contains("a finite number of zero or more, not -1"));
1221 let nameless_actuator = Rules::new().with(Rule::new("a", Condition::above("t", 1.0)).then(
1222 Action::Drive {
1223 actuator: " ".into(),
1224 on: true,
1225 },
1226 ));
1227 assert_eq!(
1228 refused(nameless_actuator),
1229 "the rule `a` drives an actuator with no name"
1230 );
1231 let filter =
1232 Rules::new().with(Rule::new("a", Condition::above("t/+", 1.0)).then(publish()));
1233 assert!(refused(filter).contains("a rule watches one topic exactly"));
1234 let broadcast = Rules::new().with(Rule::new("a", Condition::above("t", 1.0)).then(
1235 Action::Publish {
1236 topic: "alerts/#".into(),
1237 payload: "hot".into(),
1238 },
1239 ));
1240 assert!(refused(broadcast).contains("a filter rather than a topic"));
1241 }
1242
1243 #[test]
1244 fn an_evaluator_decides_what_the_engine_would_and_says_what_to_do() {
1245 let file = Rules::from_json(FILE).unwrap().with(
1246 Rule::new(
1247 "flood-alarm",
1248 Condition::above("garden/bed-1/moisture", 60.0).with_hysteresis(5.0),
1249 )
1250 .then(Action::Publish {
1251 topic: "garden/alarm".into(),
1252 payload: "waterlogged".into(),
1253 }),
1254 );
1255 let mut evaluator = RuleEvaluator::new(file).expect("a usable file");
1256 assert_eq!(evaluator.topics(), ["garden/bed-1/moisture"]);
1257 assert_eq!(evaluator.actuators(), ["bed-valve"]);
1258 assert!(evaluator.watches("garden/bed-1/moisture"));
1259 assert!(!evaluator.watches("garden/bed-2/moisture"));
1260 assert!(evaluator
1261 .evaluate("garden/bed-2/moisture", 5.0)
1262 .unwrap()
1263 .is_empty());
1264
1265 let dry = evaluator.evaluate("garden/bed-1/moisture", 28.0).unwrap();
1266 assert_eq!(dry.len(), 1);
1267 assert_eq!(
1268 (dry[0].rule.as_str(), dry[0].edge),
1269 ("water-when-dry", Edge::Set)
1270 );
1271 assert_eq!(dry[0].actions.len(), 2);
1272 assert_eq!(evaluator.is_set("water-when-dry"), Some(true));
1273
1274 let soaked = evaluator.evaluate("garden/bed-1/moisture", 65.0).unwrap();
1275 let names: Vec<&str> = soaked.iter().map(|fired| fired.rule.as_str()).collect();
1276 assert_eq!(
1277 names,
1278 ["water-when-dry", "flood-alarm"],
1279 "in the order the file lists them"
1280 );
1281 assert_eq!(soaked[0].edge, Edge::Cleared);
1282 assert_eq!(
1283 soaked[1].actions,
1284 [Action::Publish {
1285 topic: "garden/alarm".into(),
1286 payload: "waterlogged".into(),
1287 }]
1288 );
1289
1290 let failed = evaluator.evaluate("garden/bed-1/moisture", f32::NAN);
1291 assert!(
1292 matches!(&failed, Err(Error::Codec(reason)) if reason.contains("not a finite number")),
1293 "{failed:?}"
1294 );
1295 assert!(
1296 evaluator
1297 .evaluate("garden/bed-2/moisture", f32::NAN)
1298 .unwrap()
1299 .is_empty(),
1300 "a topic no rule watches is not judged"
1301 );
1302 assert_eq!(evaluator.is_set("flood-alarm"), Some(true));
1303
1304 let unusable = Rules::new().with(Rule::new("idle", Condition::above("t", 1.0)));
1305 assert!(matches!(RuleEvaluator::new(unusable), Err(Error::Codec(_))));
1306 }
1307
1308 #[tokio::test]
1309 async fn one_node_reading_drives_another_node_actuator_over_the_broker() {
1310 let broker = LoopbackBroker::new();
1311 let mut probe = LoopbackTransport::new(broker.clone());
1312 let mut watcher = LoopbackTransport::new(broker.clone());
1313 probe.connect().await.unwrap();
1314 watcher.connect().await.unwrap();
1315 watcher.subscribe("garden/bed-1/valve").await.unwrap();
1316
1317 let mut link = LoopbackTransport::new(broker);
1320 link.connect().await.unwrap();
1321 link.subscribe("garden/bed-1/note").await.unwrap();
1322
1323 let valve = Valve::default();
1324 let mut engine = RuleEngine::new(Rules::from_json(FILE).unwrap(), link, JsonCodec)
1325 .with_actuator("bed-valve", valve.clone());
1326 engine.connect().await.unwrap();
1327 assert_eq!(engine.rules().count(), 1);
1328 assert_eq!(engine.is_set("water-when-dry"), Some(false));
1329 assert_eq!(engine.is_set("nowhere"), None);
1330
1331 let mut edges = Vec::new();
1332 for reading in [42.0f32, 31.0, 28.0, 33.0, 36.0] {
1333 let payload = JsonCodec.encode(&reading).unwrap();
1334 probe.send("garden/bed-1/moisture", &payload).await.unwrap();
1335 let fired = engine.step().await.unwrap().expect("the link is up");
1336 edges.push(fired.first().map(|fired| (fired.edge, fired.reading)));
1337 }
1338 assert_eq!(
1339 edges,
1340 [
1341 None,
1342 None,
1343 Some((Edge::Set, 28.0)),
1344 None,
1345 Some((Edge::Cleared, 36.0))
1346 ]
1347 );
1348 assert_eq!(*valve.0.lock().unwrap(), vec![true, false]);
1349 assert!(!engine.is_set("water-when-dry").unwrap());
1350
1351 let open = watcher.recv().await.unwrap().expect("open");
1352 let closed = watcher.recv().await.unwrap().expect("closed");
1353 assert_eq!(
1354 (open.topic.as_str(), open.payload.as_slice()),
1355 ("garden/bed-1/valve", b"open".as_slice())
1356 );
1357 assert_eq!(closed.payload, b"closed");
1358
1359 probe
1361 .send("garden/bed-1/note", b"not a number")
1362 .await
1363 .unwrap();
1364 assert_eq!(engine.step().await.unwrap(), Some(Vec::new()));
1365
1366 let payload = JsonCodec.encode(&27.0f32).unwrap();
1368 probe.send("garden/bed-1/moisture", &payload).await.unwrap();
1369 let fired = engine.step().await.unwrap().expect("the link is up");
1370 assert_eq!(
1371 fired[0].actions,
1372 Rules::from_json(FILE).unwrap().rules[0].then
1373 );
1374 }
1375
1376 #[tokio::test]
1377 async fn an_actuator_a_rule_names_must_be_given() {
1378 let broker = LoopbackBroker::new();
1379 let mut engine = RuleEngine::new(
1380 Rules::from_json(FILE).unwrap(),
1381 LoopbackTransport::new(broker),
1382 JsonCodec,
1383 );
1384 assert!(matches!(engine.connect().await, Err(Error::Unsupported(_))));
1385 }
1386}