1use serde::de::{self, Deserializer};
11use serde::{Deserialize, Serialize};
12
13use crate::presentation::refuse;
14use crate::{format, ControlSpec, Profile};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Part {
20 Bme280,
22 Bmp280,
24 Sht3x,
26 Hdc1080,
28 Tmp117,
30 Scd4x,
32 Opt3001,
34 Ina219,
36 Ina226,
38 Ds18b20,
40 Replay,
42}
43
44impl Part {
45 pub const ALL: [Part; 11] = [
47 Part::Bme280,
48 Part::Bmp280,
49 Part::Sht3x,
50 Part::Hdc1080,
51 Part::Tmp117,
52 Part::Scd4x,
53 Part::Opt3001,
54 Part::Ina219,
55 Part::Ina226,
56 Part::Ds18b20,
57 Part::Replay,
58 ];
59
60 pub fn name(self) -> &'static str {
66 match self {
67 Part::Bme280 => "bme280",
68 Part::Bmp280 => "bmp280",
69 Part::Sht3x => "sht3x",
70 Part::Hdc1080 => "hdc1080",
71 Part::Tmp117 => "tmp117",
72 Part::Scd4x => "scd4x",
73 Part::Opt3001 => "opt3001",
74 Part::Ina219 => "ina219",
75 Part::Ina226 => "ina226",
76 Part::Ds18b20 => "ds18b20",
77 Part::Replay => "replay",
78 }
79 }
80
81 pub fn measures(self) -> &'static [(&'static str, &'static str)] {
88 const TEMPERATURE: (&str, &str) = ("temperature", "celsius");
89 const HUMIDITY: (&str, &str) = ("relative_humidity", "percent");
90 const PRESSURE: (&str, &str) = ("pressure", "hectopascal");
91 match self {
92 Part::Bme280 => &[TEMPERATURE, HUMIDITY, PRESSURE],
93 Part::Bmp280 => &[TEMPERATURE, PRESSURE],
94 Part::Sht3x | Part::Hdc1080 => &[TEMPERATURE, HUMIDITY],
95 Part::Tmp117 | Part::Ds18b20 => &[TEMPERATURE],
96 Part::Scd4x => &[("co2", "ppm"), TEMPERATURE, HUMIDITY],
97 Part::Opt3001 => &[("illuminance", "lux")],
98 Part::Ina219 | Part::Ina226 => &[("voltage", "volt")],
99 Part::Replay => &[],
100 }
101 }
102
103 pub fn default_address(self) -> Option<u8> {
109 match self {
110 Part::Bme280 | Part::Bmp280 => Some(0x76),
111 Part::Sht3x | Part::Opt3001 => Some(0x44),
112 Part::Hdc1080 | Part::Ina219 | Part::Ina226 => Some(0x40),
113 Part::Tmp117 => Some(0x48),
114 Part::Scd4x => Some(0x62),
115 Part::Ds18b20 | Part::Replay => None,
116 }
117 }
118
119 fn fixed_address(self) -> bool {
121 matches!(self, Part::Hdc1080 | Part::Scd4x)
122 }
123}
124
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct SensorWiring {
135 pub part: Part,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub bus: Option<String>,
141 #[serde(
143 default,
144 skip_serializing_if = "Option::is_none",
145 deserialize_with = "address",
146 serialize_with = "hex"
147 )]
148 pub address: Option<u8>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub serial: Option<String>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub readings: Vec<f32>,
155 #[serde(default, skip_serializing_if = "is_zero")]
158 pub offset: f32,
159 #[serde(default = "one", skip_serializing_if = "is_one")]
161 pub scale: f32,
162}
163
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
171#[serde(try_from = "OutputFile", into = "OutputFile")]
172pub enum OutputWiring {
173 Gpio {
175 chip: String,
177 line: u32,
179 active_low: bool,
182 },
183 Print {
186 label: String,
188 },
189}
190
191#[derive(Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193struct OutputFile {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 gpio: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 line: Option<u32>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 active_low: Option<bool>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 print: Option<String>,
202}
203
204impl TryFrom<OutputFile> for OutputWiring {
205 type Error = String;
206
207 fn try_from(file: OutputFile) -> Result<Self, String> {
208 match file {
209 OutputFile {
210 gpio: Some(chip),
211 line: Some(line),
212 active_low,
213 print: None,
214 } => Ok(OutputWiring::Gpio {
215 chip,
216 line,
217 active_low: active_low.unwrap_or(false),
218 }),
219 OutputFile {
220 gpio: Some(chip),
221 line: None,
222 ..
223 } => Err(format!(
224 "the output on `{chip}` needs a `line`, the GPIO number"
225 )),
226 OutputFile {
227 gpio: None,
228 line: None,
229 active_low: None,
230 print: Some(label),
231 } => Ok(OutputWiring::Print { label }),
232 OutputFile {
233 gpio: Some(_),
234 print: Some(_),
235 ..
236 } => Err("an output is one thing: a `gpio` line or `print`, not both".to_owned()),
237 OutputFile { .. } => Err(
238 "an output names a `gpio` chip with its `line`, or `print` with a label".to_owned(),
239 ),
240 }
241 }
242}
243
244impl From<OutputWiring> for OutputFile {
245 fn from(output: OutputWiring) -> Self {
246 match output {
247 OutputWiring::Gpio {
248 chip,
249 line,
250 active_low,
251 } => OutputFile {
252 gpio: Some(chip),
253 line: Some(line),
254 active_low: Some(active_low),
255 print: None,
256 },
257 OutputWiring::Print { label } => OutputFile {
258 gpio: None,
259 line: None,
260 active_low: None,
261 print: Some(label),
262 },
263 }
264 }
265}
266
267#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct TlsWiring {
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub ca: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub certificate: Option<String>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub key: Option<String>,
280}
281
282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
289#[serde(try_from = "LinkFile", into = "LinkFile")]
290pub enum LinkWiring {
291 Mqtt {
293 host: String,
295 port: u16,
297 client_id: Option<String>,
299 username: Option<String>,
301 password: Option<String>,
303 tls: Option<TlsWiring>,
305 },
306 Print,
308}
309
310#[derive(Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312struct LinkFile {
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 mqtt: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 port: Option<u16>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 client_id: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 username: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 password: Option<String>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 tls: Option<TlsWiring>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 print: Option<bool>,
327}
328
329impl TryFrom<LinkFile> for LinkWiring {
330 type Error = String;
331
332 fn try_from(file: LinkFile) -> Result<Self, String> {
333 match file {
334 LinkFile {
335 mqtt: Some(host),
336 print: None,
337 port,
338 client_id,
339 username,
340 password,
341 tls,
342 } => Ok(LinkWiring::Mqtt {
343 host,
344 port: port.unwrap_or(if tls.is_some() { 8883 } else { 1883 }),
345 client_id,
346 username,
347 password,
348 tls,
349 }),
350 LinkFile {
351 mqtt: None,
352 print: Some(true),
353 port: None,
354 client_id: None,
355 username: None,
356 password: None,
357 tls: None,
358 } => Ok(LinkWiring::Print),
359 LinkFile {
360 mqtt: Some(_),
361 print: Some(_),
362 ..
363 } => Err("a link is one thing: `mqtt` or `print`, not both".to_owned()),
364 LinkFile {
365 mqtt: None,
366 print: Some(true),
367 ..
368 } => Err("`print` takes no broker settings".to_owned()),
369 LinkFile { .. } => Err(
370 "a link names an `mqtt` broker's host, or `print`: true to print each reading"
371 .to_owned(),
372 ),
373 }
374 }
375}
376
377impl From<LinkWiring> for LinkFile {
378 fn from(link: LinkWiring) -> Self {
379 match link {
380 LinkWiring::Mqtt {
381 host,
382 port,
383 client_id,
384 username,
385 password,
386 tls,
387 } => LinkFile {
388 mqtt: Some(host),
389 port: Some(port),
390 client_id,
391 username,
392 password,
393 tls,
394 print: None,
395 },
396 LinkWiring::Print => LinkFile {
397 mqtt: None,
398 port: None,
399 client_id: None,
400 username: None,
401 password: None,
402 tls: None,
403 print: Some(true),
404 },
405 }
406 }
407}
408
409#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
413#[serde(deny_unknown_fields)]
414pub struct BatteryWiring {
415 pub part: Part,
417 pub bus: String,
419 #[serde(
421 default,
422 skip_serializing_if = "Option::is_none",
423 deserialize_with = "address",
424 serialize_with = "hex"
425 )]
426 pub address: Option<u8>,
427 pub empty_volts: f32,
429 pub full_volts: f32,
431}
432
433impl BatteryWiring {
434 pub fn charge(&self, volts: f32) -> f32 {
461 ((volts - self.empty_volts) / (self.full_volts - self.empty_volts)).clamp(0.0, 1.0)
462 }
463}
464
465#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
481#[serde(try_from = "WiringFile", into = "WiringFile")]
482pub struct Wiring {
483 pub site: String,
485 pub profile: String,
487 pub sensor: SensorWiring,
489 pub output: Option<OutputWiring>,
491 pub link: LinkWiring,
493 pub battery: Option<BatteryWiring>,
495}
496
497#[derive(Serialize, Deserialize)]
498#[serde(deny_unknown_fields)]
499struct WiringFile {
500 #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
501 schema: Option<String>,
502 site: String,
503 profile: String,
504 sensor: SensorWiring,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 output: Option<OutputWiring>,
507 link: LinkWiring,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 battery: Option<BatteryWiring>,
510}
511
512impl TryFrom<WiringFile> for Wiring {
513 type Error = String;
514
515 fn try_from(file: WiringFile) -> Result<Self, String> {
516 format::check_schema(file.schema.as_deref(), "wiring", Wiring::SCHEMA)?;
517 Ok(Wiring {
518 site: file.site,
519 profile: file.profile,
520 sensor: file.sensor,
521 output: file.output,
522 link: file.link,
523 battery: file.battery,
524 })
525 }
526}
527
528impl From<Wiring> for WiringFile {
529 fn from(wiring: Wiring) -> Self {
530 WiringFile {
531 schema: Some(Wiring::SCHEMA.to_owned()),
532 site: wiring.site,
533 profile: wiring.profile,
534 sensor: wiring.sensor,
535 output: wiring.output,
536 link: wiring.link,
537 battery: wiring.battery,
538 }
539 }
540}
541
542#[derive(Clone, Debug, PartialEq)]
545pub struct Plan {
546 pub quantity: String,
548 pub from: String,
550 pub to: String,
552 pub offset: f32,
554 pub scale: f32,
556}
557
558impl Plan {
559 pub fn apply(&self, reading: f32) -> f32 {
570 let converted = convert(reading, &self.from, &self.to).unwrap_or(reading);
571 converted * self.scale + self.offset
572 }
573}
574
575impl Wiring {
576 pub const SCHEMA: &'static str = "https://pamoja.molex.cloud/schema/wiring-1.json";
579
580 pub fn check(&self) -> pamoja_core::Result<()> {
595 if self.site.trim().is_empty() {
596 return refuse("the wiring needs a `site` name".to_owned());
597 }
598 if self.profile.trim().is_empty() {
599 return refuse("the wiring needs the `profile` it runs".to_owned());
600 }
601 let sensor = &self.sensor;
602 let part = sensor.part.name();
603 match sensor.part {
604 Part::Replay if sensor.readings.is_empty() => {
605 return refuse("a replay sensor needs `readings` to play back".to_owned())
606 }
607 Part::Replay => {}
608 Part::Ds18b20 if sensor.serial.as_deref().is_none_or(str::is_empty) => {
609 return refuse(format!(
610 "a {part} needs its 1-Wire `serial`, such as 28-0316a2795cff"
611 ))
612 }
613 Part::Ds18b20 => {}
614 _ if sensor.bus.as_deref().is_none_or(str::is_empty) => {
615 return refuse(format!(
616 "a {part} needs the I2C `bus` it is on, such as /dev/i2c-1, or sim"
617 ))
618 }
619 _ => {}
620 }
621 for address in [
622 sensor.address,
623 self.battery.as_ref().and_then(|battery| battery.address),
624 ]
625 .into_iter()
626 .flatten()
627 {
628 if address > 0x7f {
629 return refuse(format!(
630 "the address {address:#04x} is past 0x7f, the last of I2C's seven-bit addresses"
631 ));
632 }
633 }
634 if let (Some(address), Some(usual)) = (sensor.address, sensor.part.default_address()) {
635 if sensor.part.fixed_address() && address != usual {
636 return refuse(format!(
637 "a {part} answers only at {usual:#04x}, not {address:#04x}"
638 ));
639 }
640 }
641 if !(sensor.offset.is_finite() && sensor.scale.is_finite() && sensor.scale != 0.0) {
642 return refuse(
643 "a sensor's `offset` and `scale` are finite numbers, and the scale is not 0"
644 .to_owned(),
645 );
646 }
647 if let Some(OutputWiring::Gpio { chip, .. }) = &self.output {
648 if chip.trim().is_empty() {
649 return refuse(
650 "a GPIO output needs its `gpio` chip, such as /dev/gpiochip0".to_owned(),
651 );
652 }
653 }
654 if let LinkWiring::Mqtt {
655 host,
656 port,
657 username,
658 password,
659 ..
660 } = &self.link
661 {
662 if host.trim().is_empty() {
663 return refuse("an MQTT link needs the broker's host".to_owned());
664 }
665 if *port == 0 {
666 return refuse("an MQTT link's port is 1 to 65535".to_owned());
667 }
668 if password.is_some() && username.is_none() {
669 return refuse("an MQTT password goes with a username".to_owned());
670 }
671 }
672 if let LinkWiring::Mqtt { tls: Some(tls), .. } = &self.link {
673 if tls.certificate.is_some() != tls.key.is_some() {
674 return refuse(
675 "a TLS client `certificate` and its `key` are given together".to_owned(),
676 );
677 }
678 }
679 if let Some(battery) = &self.battery {
680 if !matches!(battery.part, Part::Ina219 | Part::Ina226) {
681 return refuse(format!(
682 "a battery is read through a power monitor, an ina219 or an ina226, not a {}",
683 battery.part.name()
684 ));
685 }
686 if battery.bus.trim().is_empty() {
687 return refuse("a battery's monitor needs the I2C `bus` it is on".to_owned());
688 }
689 if !(battery.empty_volts.is_finite()
690 && battery.full_volts.is_finite()
691 && battery.empty_volts < battery.full_volts)
692 {
693 return refuse(format!(
694 "a battery's `full_volts` ({}) is above its `empty_volts` ({})",
695 battery.full_volts, battery.empty_volts
696 ));
697 }
698 }
699 Ok(())
700 }
701
702 pub fn fits(&self, profile: &Profile) -> pamoja_core::Result<Plan> {
743 let name = &profile.name;
744 let Some(reads) = &profile.reads else {
745 return Err(pamoja_core::Error::Codec(format!(
746 "the profile `{name}` does not say what it reads, so the runner cannot pick a reading from the part; give it a `reads`"
747 )));
748 };
749 let part = self.sensor.part;
750 let from = if part == Part::Replay {
751 reads.unit.clone()
752 } else {
753 match part
754 .measures()
755 .iter()
756 .find(|(quantity, _)| *quantity == reads.quantity)
757 {
758 Some((_, unit)) => (*unit).to_owned(),
759 None => {
760 let offered: Vec<&str> = part
761 .measures()
762 .iter()
763 .map(|(quantity, _)| *quantity)
764 .collect();
765 return Err(pamoja_core::Error::Codec(format!(
766 "the profile `{name}` reads {}, and a {} measures {}",
767 reads.quantity,
768 part.name(),
769 words(&offered)
770 )));
771 }
772 }
773 };
774 if convert(0.0, &from, &reads.unit).is_none() {
775 return Err(pamoja_core::Error::Codec(format!(
776 "a {} reads {} in {from}, and the runner does not convert that to the profile's {}",
777 part.name(),
778 reads.quantity,
779 reads.unit
780 )));
781 }
782 if let ControlSpec::Custom { kind, .. } = &profile.control {
783 return Err(pamoja_core::Error::Codec(format!(
784 "the profile `{name}` names the control kind `{kind}`, which only a program of its own can decide; the runner runs the built-in kinds"
785 )));
786 }
787 let drives = matches!(profile.control, ControlSpec::Setpoint { .. });
788 match (drives, &self.output) {
789 (true, None) => Err(pamoja_core::Error::Codec(format!(
790 "the profile `{name}` switches an output, and the wiring has no `output` for it"
791 ))),
792 (false, Some(_)) => Err(pamoja_core::Error::Codec(format!(
793 "the profile `{name}` switches nothing, so the wiring's `output` would never move; leave it out"
794 ))),
795 _ => Ok(Plan {
796 quantity: reads.quantity.clone(),
797 from,
798 to: reads.unit.clone(),
799 offset: self.sensor.offset,
800 scale: self.sensor.scale,
801 }),
802 }
803 }
804}
805
806#[cfg(feature = "json")]
807impl Wiring {
808 pub fn from_json(text: &str) -> pamoja_core::Result<Self> {
825 let wiring: Wiring = serde_json::from_str(text)
826 .map_err(|error| pamoja_core::Error::Codec(format::explain(&error)))?;
827 wiring.check()?;
828 Ok(wiring)
829 }
830
831 pub fn to_json(&self) -> pamoja_core::Result<String> {
842 serde_json::to_string_pretty(self)
843 .map_err(|error| pamoja_core::Error::Codec(error.to_string()))
844 }
845}
846
847pub fn convert(value: f32, from: &str, to: &str) -> Option<f32> {
872 if from == to {
873 return Some(value);
874 }
875 let celsius = match from {
876 "celsius" => Some(value),
877 "fahrenheit" => Some((value - 32.0) * 5.0 / 9.0),
878 "kelvin" => Some(value - 273.15),
879 _ => None,
880 };
881 if let Some(celsius) = celsius {
882 return match to {
883 "celsius" => Some(celsius),
884 "fahrenheit" => Some(celsius * 9.0 / 5.0 + 32.0),
885 "kelvin" => Some(celsius + 273.15),
886 _ => None,
887 };
888 }
889 let pascal = |unit: &str| match unit {
890 "pascal" => Some(1.0),
891 "hectopascal" | "millibar" => Some(100.0),
892 "kilopascal" => Some(1000.0),
893 "bar" => Some(100_000.0),
894 _ => None,
895 };
896 let volt = |unit: &str| match unit {
897 "volt" => Some(1000.0),
898 "millivolt" => Some(1.0),
899 _ => None,
900 };
901 let (a, b) = match (pascal(from), pascal(to), volt(from), volt(to)) {
902 (Some(a), Some(b), _, _) | (_, _, Some(a), Some(b)) => (a, b),
903 _ => return None,
904 };
905 Some((f64::from(value) * a / b) as f32)
906}
907
908fn words(names: &[&str]) -> String {
910 match names {
911 [] => "nothing".to_owned(),
912 [one] => (*one).to_owned(),
913 [first, second] => format!("{first} or {second}"),
914 [rest @ .., last] => format!("{}, or {last}", rest.join(", ")),
915 }
916}
917
918fn address<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u8>, D::Error> {
919 #[derive(Deserialize)]
920 #[serde(untagged)]
921 enum Written {
922 Number(u8),
923 Text(String),
924 }
925 match Option::<Written>::deserialize(deserializer)? {
926 None => Ok(None),
927 Some(Written::Number(address)) => Ok(Some(address)),
928 Some(Written::Text(text)) => {
929 let digits = text
930 .strip_prefix("0x")
931 .or_else(|| text.strip_prefix("0X"))
932 .ok_or_else(|| {
933 de::Error::custom(format!(
934 "the address `{text}` is a number, or hexadecimal such as \"0x76\""
935 ))
936 })?;
937 u8::from_str_radix(digits, 16).map(Some).map_err(|_| {
938 de::Error::custom(format!(
939 "the address `{text}` is not one byte of hexadecimal"
940 ))
941 })
942 }
943 }
944}
945
946fn hex<S: serde::Serializer>(address: &Option<u8>, serializer: S) -> Result<S::Ok, S::Error> {
947 match address {
948 Some(address) => serializer.serialize_str(&format!("{address:#04x}")),
949 None => serializer.serialize_none(),
950 }
951}
952
953fn one() -> f32 {
954 1.0
955}
956
957fn is_one(value: &f32) -> bool {
958 *value == 1.0
959}
960
961fn is_zero(value: &f32) -> bool {
962 *value == 0.0
963}
964
965#[cfg(all(test, feature = "json"))]
966mod tests {
967 use super::*;
968
969 const COOP: &str = r#"{
970 "$schema": "https://pamoja.molex.cloud/schema/wiring-1.json",
971 "site": "coop-2",
972 "profile": "brooder-heater.json",
973 "sensor": { "part": "bme280", "bus": "/dev/i2c-1", "address": "0x77", "offset": -0.4 },
974 "output": { "gpio": "/dev/gpiochip0", "line": 17, "active_low": true },
975 "link": { "mqtt": "192.168.1.10", "username": "coop", "password": "hunter2" },
976 "battery": { "part": "ina219", "bus": "/dev/i2c-1", "empty_volts": 3.3, "full_volts": 4.2 }
977}"#;
978
979 fn refused(text: &str) -> String {
980 match Wiring::from_json(text) {
981 Err(pamoja_core::Error::Codec(reason)) => reason,
982 other => panic!("expected the wiring to be refused, got {other:?}"),
983 }
984 }
985
986 #[test]
987 fn a_wiring_file_loads_and_writes_back_the_same() {
988 let wiring = Wiring::from_json(COOP).expect("a usable wiring");
989 assert_eq!(wiring.sensor.address, Some(0x77));
990 assert_eq!(wiring.sensor.offset, -0.4);
991 assert_eq!(
992 wiring.output,
993 Some(OutputWiring::Gpio {
994 chip: "/dev/gpiochip0".to_owned(),
995 line: 17,
996 active_low: true
997 })
998 );
999 assert!(matches!(wiring.link, LinkWiring::Mqtt { port: 1883, .. }));
1000 let written = wiring.to_json().unwrap();
1001 assert!(written.contains("\"address\": \"0x77\""), "{written}");
1002 assert!(written
1003 .starts_with("{\n \"$schema\": \"https://pamoja.molex.cloud/schema/wiring-1.json\""));
1004 assert_eq!(Wiring::from_json(&written).unwrap(), wiring);
1005
1006 let decimal = COOP.replace("\"0x77\"", "119");
1007 assert_eq!(
1008 Wiring::from_json(&decimal).unwrap().sensor.address,
1009 Some(0x77)
1010 );
1011 let tls = COOP.replace(
1012 "\"password\": \"hunter2\"",
1013 "\"password\": \"hunter2\", \"tls\": {}",
1014 );
1015 assert!(matches!(
1016 Wiring::from_json(&tls).unwrap().link,
1017 LinkWiring::Mqtt { port: 8883, .. }
1018 ));
1019 }
1020
1021 #[test]
1022 fn a_wiring_file_is_refused_with_the_reason() {
1023 assert!(refused(&COOP.replace("\"site\"", "\"stie\""))
1024 .starts_with("unknown field `stie`, did you mean `site`?"));
1025 assert!(refused(&COOP.replace("\"0x77\"", "\"77\"")).contains("hexadecimal such as"));
1026 assert!(refused(&COOP.replace("\"bme280\"", "\"bme28O\""))
1027 .starts_with("unknown variant `bme28O`, did you mean `bme280`?"));
1028 assert!(refused(&COOP.replace(", \"line\": 17", "")).contains("needs a `line`"));
1029 assert!(
1030 refused(&COOP.replace("\"username\": \"coop\", ", "")).contains("goes with a username")
1031 );
1032 assert!(
1033 refused(&COOP.replace("\"bus\": \"/dev/i2c-1\", \"address\"", "\"address\""))
1034 .contains("needs the I2C `bus`")
1035 );
1036 assert!(
1037 refused(&COOP.replace("\"part\": \"ina219\"", "\"part\": \"tmp117\""))
1038 .contains("through a power monitor")
1039 );
1040 assert!(refused(&COOP.replace("4.2", "3.0")).contains("is above its `empty_volts`"));
1041 assert!(
1042 refused(&COOP.replace("wiring-1.json", "wiring-2.json")).contains("wiring format 2")
1043 );
1044 let fixed = COOP
1045 .replace("\"bme280\"", "\"scd4x\"")
1046 .replace("\"0x77\"", "\"0x61\"");
1047 assert!(refused(&fixed).contains("answers only at 0x62"));
1048 let both = COOP.replace(
1049 "\"active_low\": true",
1050 "\"active_low\": true, \"print\": \"lamp\"",
1051 );
1052 assert!(refused(&both).contains("not both"));
1053 }
1054
1055 #[test]
1056 fn a_wiring_fits_a_profile_that_reads_what_its_part_measures() {
1057 let wiring = Wiring::from_json(COOP).unwrap();
1058 let brooder = Profile::vaccine_fridge_monitor();
1059 let plan = wiring
1060 .fits(&brooder)
1061 .expect("a thermometer runs a temperature profile");
1062 assert_eq!(plan.apply(20.44), 20.44 - 0.4);
1063
1064 let fahrenheit = brooder.clone().with_reads("temperature", "fahrenheit");
1065 let plan = wiring.fits(&fahrenheit).unwrap();
1066 assert!((plan.apply(100.0) - (212.0 - 0.4)).abs() < 1e-3);
1067
1068 let reason = |profile: &Profile| wiring.fits(profile).unwrap_err().to_string();
1069 assert!(reason(&Profile::irrigation_node())
1070 .contains("reads soil_moisture, and a bme280 measures temperature, relative_humidity, or pressure"));
1071 assert!(
1072 reason(&brooder.clone().with_reads("temperature", "percent"))
1073 .contains("does not convert")
1074 );
1075 let mut unread = brooder.clone();
1076 unread.reads = None;
1077 assert!(reason(&unread).contains("does not say what it reads"));
1078 let mut custom = brooder.clone();
1079 custom.control = ControlSpec::custom("brooder_guard", crate::Params::new()).unwrap();
1080 assert!(reason(&custom).contains("only a program of its own can decide"));
1081 let mut monitor = brooder.clone();
1082 monitor.control = ControlSpec::Monitor;
1083 assert!(reason(&monitor).contains("would never move"));
1084 let mut unwired = wiring.clone();
1085 unwired.output = None;
1086 assert!(unwired
1087 .fits(&brooder)
1088 .unwrap_err()
1089 .to_string()
1090 .contains("no `output` for it"));
1091 }
1092
1093 #[test]
1094 fn units_convert_among_their_own_kind() {
1095 assert_eq!(convert(0.0, "celsius", "kelvin"), Some(273.15));
1096 assert_eq!(convert(212.0, "fahrenheit", "celsius"), Some(100.0));
1097 assert_eq!(convert(1.0, "bar", "hectopascal"), Some(1000.0));
1098 assert_eq!(convert(3.3, "volt", "millivolt"), Some(3300.0));
1099 assert_eq!(convert(1.0, "volt", "celsius"), None);
1100 assert_eq!(convert(7.0, "ntu", "ntu"), Some(7.0));
1101 assert_eq!(words(&["a", "b", "c"]), "a, b, or c");
1102 }
1103}