Skip to main content

pamoja_profile/
wiring.rs

1//! A wiring file: which part reads what a profile reads, which line its output drives, and
2//! which link its readings go over, for one site.
3//!
4//! A profile is portable: it says what to measure and how to decide, and the same file
5//! runs a brooder in one village and another. A wiring file is the rest, and it belongs to
6//! one installation: the part on this board's bus, the relay on this board's header, the
7//! broker on this farm's network. `pamoja-node` reads the two and runs the node, with no
8//! program to write.
9
10use serde::de::{self, Deserializer};
11use serde::{Deserialize, Serialize};
12
13use crate::presentation::refuse;
14use crate::{format, ControlSpec, Profile};
15
16/// A part the stock runner reads, as a wiring file names it.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Part {
20    /// Bosch BME280: temperature, relative humidity, and pressure, on I2C.
21    Bme280,
22    /// Bosch BMP280: temperature and pressure, on I2C.
23    Bmp280,
24    /// Sensirion SHT3x: temperature and relative humidity, on I2C.
25    Sht3x,
26    /// TI HDC1080: temperature and relative humidity, on I2C at 0x40.
27    Hdc1080,
28    /// TI TMP117: temperature, on I2C.
29    Tmp117,
30    /// Sensirion SCD4x: carbon dioxide, temperature, and relative humidity, on I2C at 0x62.
31    Scd4x,
32    /// TI OPT3001: illuminance, on I2C.
33    Opt3001,
34    /// TI INA219: bus voltage, on I2C.
35    Ina219,
36    /// TI INA226: bus voltage, on I2C.
37    Ina226,
38    /// Maxim DS18B20: temperature, through the Linux kernel's 1-Wire files.
39    Ds18b20,
40    /// A list of readings played back in turn, for trying a profile with nothing wired.
41    Replay,
42}
43
44impl Part {
45    /// Every part the runner reads, in the order the documentation lists them.
46    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    /// Returns the part as a wiring file names it.
61    ///
62    /// # Returns
63    ///
64    /// The name, such as `"bme280"`.
65    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    /// Returns what the part measures and the unit each comes out in.
82    ///
83    /// # Returns
84    ///
85    /// Each quantity with its unit, as a profile's `reads` names them. A replay part
86    /// reads whatever the profile reads, so it lists nothing.
87    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    /// Returns the I2C address the part answers at unless the wiring names another.
104    ///
105    /// # Returns
106    ///
107    /// The address, or `None` for a part that is not on I2C.
108    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    /// Whether the part answers only at its one address.
120    fn fixed_address(self) -> bool {
121        matches!(self, Part::Hdc1080 | Part::Scd4x)
122    }
123}
124
125/// The part that takes the readings, and where it is.
126///
127/// In a wiring file it is the `sensor` object:
128///
129/// ```json
130/// { "part": "bme280", "bus": "/dev/i2c-1", "address": "0x76" }
131/// ```
132#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct SensorWiring {
135    /// The part.
136    pub part: Part,
137    /// The I2C bus the part is on, such as `/dev/i2c-1`, or `sim` for the part's simulated
138    /// twin, which answers with one fixed measurement.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub bus: Option<String>,
141    /// The part's I2C address, when it is not the part's usual one.
142    #[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    /// A DS18B20's 1-Wire serial, such as `28-0316a2795cff`, as the kernel names its folder.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub serial: Option<String>,
152    /// The readings a replay part plays back in turn, in the unit the profile reads.
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub readings: Vec<f32>,
155    /// Added to each reading after it is in the profile's unit, to correct a probe that
156    /// reads high or low.
157    #[serde(default, skip_serializing_if = "is_zero")]
158    pub offset: f32,
159    /// Multiplied into each reading before the offset is added.
160    #[serde(default = "one", skip_serializing_if = "is_one")]
161    pub scale: f32,
162}
163
164/// The output a profile drives, as a wiring file names it.
165///
166/// ```json
167/// { "gpio": "/dev/gpiochip0", "line": 17, "active_low": true }
168/// { "print": "heat lamp" }
169/// ```
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
171#[serde(try_from = "OutputFile", into = "OutputFile")]
172pub enum OutputWiring {
173    /// A GPIO line, such as the input of a relay board.
174    Gpio {
175        /// The GPIO chip, such as `/dev/gpiochip0`.
176        chip: String,
177        /// The line on the chip, which on a Raspberry Pi is the GPIO number.
178        line: u32,
179        /// Whether the line is driven low to switch the output on, as most relay boards
180        /// want.
181        active_low: bool,
182    },
183    /// A line of text each time the output switches, for trying a profile with nothing
184    /// wired.
185    Print {
186        /// What the output is called in the text, such as `heat lamp`.
187        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/// A broker's TLS settings, as a wiring file names them.
268#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct TlsWiring {
271    /// The certificate authority to trust, as a PEM file; the system's own unless given.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub ca: Option<String>,
274    /// The client certificate to present, as a PEM file, for a broker that asks for one.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub certificate: Option<String>,
277    /// The client certificate's private key, as a PEM file.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub key: Option<String>,
280}
281
282/// The link the readings are published over, as a wiring file names it.
283///
284/// ```json
285/// { "mqtt": "192.168.1.10", "port": 1883 }
286/// { "print": true }
287/// ```
288#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
289#[serde(try_from = "LinkFile", into = "LinkFile")]
290pub enum LinkWiring {
291    /// An MQTT broker.
292    Mqtt {
293        /// The broker's host name or address.
294        host: String,
295        /// The broker's port.
296        port: u16,
297        /// The client id; the site's name unless given.
298        client_id: Option<String>,
299        /// The username to sign in with.
300        username: Option<String>,
301        /// The password to sign in with.
302        password: Option<String>,
303        /// TLS to the broker, when it speaks it.
304        tls: Option<TlsWiring>,
305    },
306    /// A line of text for each reading, for trying a profile with no broker.
307    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/// The battery the node runs from, read as a voltage to set how often it samples.
410///
411/// A node with none samples at its profile's active cadence, as on mains power.
412#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
413#[serde(deny_unknown_fields)]
414pub struct BatteryWiring {
415    /// The power monitor across the battery: `ina219` or `ina226`.
416    pub part: Part,
417    /// The I2C bus the monitor is on, or `sim`.
418    pub bus: String,
419    /// The monitor's I2C address, when it is not the part's usual one.
420    #[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    /// The voltage the battery reads when it is empty.
428    pub empty_volts: f32,
429    /// The voltage the battery reads when it is full.
430    pub full_volts: f32,
431}
432
433impl BatteryWiring {
434    /// Turns the battery's voltage into a state of charge.
435    ///
436    /// # Arguments
437    ///
438    /// * `volts` - the voltage the monitor read.
439    ///
440    /// # Returns
441    ///
442    /// The charge from 0 to 1, on a straight line from empty to full; a reading outside the
443    /// two is held to the nearer end.
444    ///
445    /// # Examples
446    ///
447    /// ```
448    /// use pamoja_profile::wiring::{BatteryWiring, Part};
449    ///
450    /// let battery = BatteryWiring {
451    ///     part: Part::Ina219,
452    ///     bus: "/dev/i2c-1".to_owned(),
453    ///     address: None,
454    ///     empty_volts: 3.3,
455    ///     full_volts: 4.2,
456    /// };
457    /// assert!((battery.charge(3.75) - 0.5).abs() < 1e-6);
458    /// assert_eq!(battery.charge(5.0), 1.0);
459    /// ```
460    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/// One site's wiring: the part, the output, the link, and the battery a profile runs with.
466///
467/// ```json
468/// {
469///   "$schema": "https://pamoja.molex.cloud/schema/wiring-1.json",
470///   "site": "coop-2",
471///   "profile": "brooder-heater.json",
472///   "sensor": { "part": "bme280", "bus": "/dev/i2c-1", "address": "0x76" },
473///   "output": { "gpio": "/dev/gpiochip0", "line": 17, "active_low": true },
474///   "link": { "mqtt": "192.168.1.10" }
475/// }
476/// ```
477///
478/// Like a profile it is refused, with the field it was probably meant to be, when it holds a
479/// field its format does not have.
480#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
481#[serde(try_from = "WiringFile", into = "WiringFile")]
482pub struct Wiring {
483    /// The site's name, which the node's logs and its MQTT client id carry.
484    pub site: String,
485    /// The profile to run, as a path relative to the wiring file.
486    pub profile: String,
487    /// The part that takes the readings.
488    pub sensor: SensorWiring,
489    /// The output the profile drives, if it drives one.
490    pub output: Option<OutputWiring>,
491    /// The link the readings are published over.
492    pub link: LinkWiring,
493    /// The battery the node runs from, if it runs from one.
494    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/// How a profile's reading is taken from a wired part: which of the part's quantities, and
543/// how it is brought into the profile's unit.
544#[derive(Clone, Debug, PartialEq)]
545pub struct Plan {
546    /// The quantity, as the profile names it.
547    pub quantity: String,
548    /// The unit the part reads it in.
549    pub from: String,
550    /// The unit the profile's numbers are in.
551    pub to: String,
552    /// The correction the wiring adds after the unit is right.
553    pub offset: f32,
554    /// The factor the wiring multiplies in before the offset.
555    pub scale: f32,
556}
557
558impl Plan {
559    /// Brings one of the part's readings into the profile's unit and applies the wiring's
560    /// correction.
561    ///
562    /// # Arguments
563    ///
564    /// * `reading` - the reading in the part's unit.
565    ///
566    /// # Returns
567    ///
568    /// The reading the profile judges.
569    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    /// The address of the published JSON Schema for the wiring file format this build
577    /// writes.
578    pub const SCHEMA: &'static str = "https://pamoja.molex.cloud/schema/wiring-1.json";
579
580    /// Checks the wiring for what a runner could not open.
581    ///
582    /// # Returns
583    ///
584    /// Nothing when every part of the wiring can be opened.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) naming the first problem: an
589    /// empty site or profile path; an I2C part with no bus, a DS18B20 with no serial, a
590    /// replay with no readings, an address past 0x7f or on a part that answers at only one,
591    /// or a calibration that is not a finite number; a GPIO output with no chip; an MQTT link
592    /// with no host, port 0, or a password without a username; or a battery on a part that
593    /// is not a power monitor, or whose full voltage is not above its empty one.
594    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    /// Checks that this wiring can run a profile, and says how its reading is taken.
703    ///
704    /// # Arguments
705    ///
706    /// * `profile` - the profile to run.
707    ///
708    /// # Returns
709    ///
710    /// The plan for the reading: the part's quantity and how it reaches the profile's unit.
711    ///
712    /// # Errors
713    ///
714    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) when the profile does not say
715    /// what it reads, the part does not measure it, the part's unit does not convert to the
716    /// profile's, the profile's control kind is one only a program of its own can decide,
717    /// a profile that switches an output has none wired, or one that switches nothing has
718    /// one wired that would never move.
719    ///
720    /// # Examples
721    ///
722    /// ```
723    /// use pamoja_profile::wiring::Wiring;
724    /// use pamoja_profile::Profile;
725    ///
726    /// let wiring = Wiring::from_json(r#"{
727    ///     "site": "coop-2",
728    ///     "profile": "brooder-heater.json",
729    ///     "sensor": { "part": "bme280", "bus": "/dev/i2c-1" },
730    ///     "output": { "gpio": "/dev/gpiochip0", "line": 17, "active_low": true },
731    ///     "link": { "mqtt": "192.168.1.10" }
732    /// }"#)?;
733    /// let fridge = Profile::vaccine_fridge_monitor();
734    /// let plan = wiring.fits(&fridge)?;
735    /// assert_eq!((plan.quantity.as_str(), plan.from.as_str()), ("temperature", "celsius"));
736    ///
737    /// // A soil probe's profile cannot run on a thermometer.
738    /// let refused = wiring.fits(&Profile::irrigation_node()).unwrap_err().to_string();
739    /// assert!(refused.contains("a bme280 measures temperature, relative_humidity, or pressure"));
740    /// # Ok::<(), pamoja_core::Error>(())
741    /// ```
742    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    /// Loads a wiring file.
809    ///
810    /// # Arguments
811    ///
812    /// * `text` - the file's contents.
813    ///
814    /// # Returns
815    ///
816    /// The wiring.
817    ///
818    /// # Errors
819    ///
820    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the text is not a wiring file,
821    /// is written in a format this build does not read, carries a field the format does not
822    /// have, with the one it was probably meant to be, or describes wiring
823    /// [`check`](Wiring::check) refuses.
824    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    /// Writes the wiring as its file.
832    ///
833    /// # Returns
834    ///
835    /// The pretty-printed JSON text, which names its format with `$schema`.
836    ///
837    /// # Errors
838    ///
839    /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the wiring cannot be
840    /// serialized.
841    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
847/// Converts a reading between two units of the same quantity.
848///
849/// # Arguments
850///
851/// * `value` - the reading.
852/// * `from` - the unit it is in, such as `celsius`.
853/// * `to` - the unit wanted, such as `fahrenheit`.
854///
855/// # Returns
856///
857/// The reading in `to`, or `None` when the runner does not convert between the two. A unit
858/// converts to itself, and temperature (`celsius`, `fahrenheit`, `kelvin`), pressure
859/// (`pascal`, `hectopascal`, `millibar`, `kilopascal`, `bar`), and electrical units
860/// (`volt` and `millivolt`) convert among their own kind.
861///
862/// # Examples
863///
864/// ```
865/// use pamoja_profile::wiring::convert;
866///
867/// assert_eq!(convert(100.0, "celsius", "fahrenheit"), Some(212.0));
868/// assert_eq!(convert(1013.25, "hectopascal", "kilopascal"), Some(101.325));
869/// assert_eq!(convert(20.0, "celsius", "percent"), None);
870/// ```
871pub 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
908/// Lists names the way a sentence does: `a`, `a or b`, `a, b, or c`.
909fn 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}