Skip to main content

pamoja_profile/
lib.rs

1//! Device profiles: named, ready-to-run nodes assembled from pamoja capabilities.
2//!
3//! Most people who can put a sensor to good use are not electrical engineers, and the
4//! gap between "I can read a sensor" and "I built something that works and warns me when
5//! it fails" is wiring, tuning, and glue code. A device profile closes that gap. It is a
6//! named, pre-wired bundle - a control policy, a publish topic, and a power schedule -
7//! that a builder instantiates instead of choosing algorithms and constants by hand.
8//!
9//! This guide runs from the simplest use to a fully themed dashboard. Skip to
10//! [Which pieces do I need?](#which-pieces-do-i-need) for a one-line map.
11//!
12//! # The shape of a profile
13//!
14//! - [`Profile`] is the manifest: plain data a community can publish and share, carrying
15//!   a [`ControlSpec`], a [`PowerSchedule`], an optional [`Presentation`], and a
16//!   description of what it is for. It
17//!   serializes to and from JSON with [`Profile::to_json`] and [`Profile::from_json`], so
18//!   a profile ships as a file and loads onto a device. The presets
19//!   [`Profile::vaccine_fridge_monitor`], [`Profile::irrigation_node`],
20//!   [`Profile::well_level`], and [`Profile::flood_sensor`] are convenience constructors
21//!   for the same data.
22//! - [`Node`] is what the runtime assembles from a profile and real components: a
23//!   [`Sensor`](pamoja_core::Sensor), an [`Actuator`](pamoja_core::Actuator), a
24//!   [`Transport`](pamoja_core::Transport), and a [`Codec`](pamoja_codec::Codec). Each
25//!   [`tick`](Node::tick) reads, decides, drives the output, and publishes.
26//! - [`wiring::Wiring`] is one site's wiring file: the part that takes the readings, the
27//!   GPIO line the output drives, the link, and the battery. With the `runner` feature, the
28//!   `pamoja-node` binary reads it beside a profile and runs the node with no program to
29//!   write.
30//!
31//! The decision logic is a [`Controller`] that composes the `pamoja-kit` helpers, so a
32//! profile is glue over field-tested math rather than new behavior. Its I/O is async; its
33//! decisions are synchronous and hardware-free, so a whole control policy is unit-testable
34//! with no devices and no network.
35//!
36//! # Start simple: pick a preset
37//!
38//! The quickest path is a named preset. Hand its controller a reading and it decides:
39//!
40//! ```
41//! use pamoja_profile::{Alert, Profile};
42//!
43//! let mut control = Profile::vaccine_fridge_monitor().controller()?;
44//!
45//! // A warm fridge: the cooler runs and a spoilage excursion is flagged.
46//! let reaction = control.evaluate(9.0);
47//! assert_eq!(reaction.actuator, Some(true));
48//! assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
49//! # Ok::<(), pamoja_core::Error>(())
50//! ```
51//!
52//! # The manifest: write it, share it, load it
53//!
54//! A profile is just data, so a community can write one as JSON, store it in a file, and
55//! share it - no code. [`Profile::from_json`] loads it and [`Profile::to_json`] writes it
56//! back; the power thresholds are optional and default when omitted. The `$schema` line
57//! names the format the file is written in ([`FORMAT`]), which an editor reads to check
58//! the file as it is typed, and `reads` says what the node measures. A field the format
59//! does not have is refused, with the one it was probably meant to be.
60//!
61//! ```
62//! use pamoja_profile::Profile;
63//!
64//! let manifest = r#"{
65//!     "$schema": "https://pamoja.molex.cloud/schema/profile-1.json",
66//!     "name": "rain-tank",
67//!     "reads": { "quantity": "water_level", "unit": "meter" },
68//!     "topic": "water/tank/level",
69//!     "control": { "kind": "level", "empty": 0.0, "warn_within": 5 },
70//!     "power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
71//! }"#;
72//!
73//! let profile = Profile::from_json(manifest).expect("a valid manifest");
74//! assert_eq!(profile.name, "rain-tank");
75//! assert!(profile.to_json().unwrap().contains("rain-tank"));
76//! ```
77//!
78//! # Control policies
79//!
80//! Every profile names one [`ControlSpec`], the rule applied to each reading:
81//!
82//! - `Setpoint` holds a value by switching an output on and off (a fridge's cooler, an
83//!   irrigation valve) and alerts when the reading leaves a safe band.
84//! - `Level` watches a falling level and warns before it reaches empty.
85//! - `Surge` warns when a reading changes faster than a safe rate (a flash flood).
86//! - `Monitor` only reports, with no output and no alert.
87//! - Any other `kind` is a custom policy: its parameters ride beside it in the
88//!   manifest, and a [`PolicyRegistry`] resolves the kind to your code.
89//!
90//! Every field is public, so a deployment can build or tune a policy in place:
91//!
92//! ```
93//! use pamoja_profile::{ControlSpec, PowerSchedule, Profile};
94//!
95//! // Hold soil moisture near 35% by opening a valve - a "heater" for moisture.
96//! let profile = Profile {
97//!     name: "drip-node".to_owned(),
98//!     description: None,
99//!     reads: None,
100//!     topic: "farm/soil-moisture".to_owned(),
101//!     control: ControlSpec::Setpoint { setpoint: 35.0, hysteresis: 5.0, cooling: false, safe_band: 25.0 },
102//!     power: PowerSchedule::new(300, 1800, 3600),
103//!     presentation: None,
104//! };
105//! let mut control = profile.controller()?;
106//! assert_eq!(control.evaluate(28.0).actuator, Some(true)); // dry: the valve opens
107//! # Ok::<(), pamoja_core::Error>(())
108//! ```
109//!
110//! # Your own policy
111//!
112//! The built-in kinds decide one number into an on/off setting. A [`Policy`] of your own
113//! decides whatever a driver reads into whatever an actuator takes, raises its own
114//! [`Alert::Custom`] conditions, and runs in the same node through
115//! [`Node::with_policy`]:
116//!
117//! ```
118//! use pamoja_profile::{Alert, Policy, Reaction};
119//!
120//! // Frost settles when the air is cold and damp, so this reads both at once.
121//! struct FrostGuard {
122//!     warn_below: f32,
123//! }
124//!
125//! impl Policy for FrostGuard {
126//!     type Reading = (f32, f32);
127//!     type Command = bool;
128//!
129//!     fn evaluate(&mut self, &(temperature, humidity): &(f32, f32)) -> Reaction {
130//!         let risk = temperature < self.warn_below && humidity > 80.0;
131//!         Reaction {
132//!             actuator: Some(risk),
133//!             alert: risk.then_some(Alert::Custom { code: "FrostRisk", value: temperature }),
134//!         }
135//!     }
136//! }
137//!
138//! let mut guard = FrostGuard { warn_below: 2.0 };
139//! assert_eq!(guard.evaluate(&(1.0, 92.0)).alert.map(Alert::kind), Some("FrostRisk"));
140//! ```
141//!
142//! # A kind the library never shipped
143//!
144//! A manifest may name a control kind of your own, with its parameters beside it, and
145//! a [`PolicyRegistry`] turns that kind into the policy that decides it, so a policy
146//! written once ships as data like every other:
147//!
148//! ```
149//! use pamoja_core::Error;
150//! use pamoja_profile::{BoxedPolicy, Controller, PolicyRegistry, Profile};
151//!
152//! let manifest = r#"{
153//!     "name": "orchard-frost",
154//!     "topic": "orchard/air/temperature",
155//!     "control": { "kind": "frost_guard", "warn_below": 2.0 },
156//!     "power": { "active_secs": 60, "saver_secs": 300, "critical_secs": 900 }
157//! }"#;
158//! let profile = Profile::from_json(manifest)?;
159//!
160//! // Here the kind reuses a built-in controller as its policy; any `Policy` will do.
161//! let registry = PolicyRegistry::new().register("frost_guard", |params| {
162//!     let warn_below = params
163//!         .number("warn_below")
164//!         .ok_or(Error::Unsupported("frost_guard needs `warn_below`"))?;
165//!     Ok(Box::new(Controller::setpoint(warn_below as f32, 0.5, false, 5.0)) as BoxedPolicy)
166//! });
167//! let mut policy = registry.resolve(&profile.control)?;
168//! assert_eq!(policy.evaluate(&1.0).actuator, Some(true)); // cold: the heater comes on
169//! # Ok::<(), Error>(())
170//! ```
171//!
172//! # Rules between nodes
173//!
174//! A profile decides at the node that reads. A [`Rule`] decides between nodes: it watches
175//! one topic for a reading crossing a line and, the moment it does, drives an actuator
176//! the engine holds by name or publishes a message, over the same link. Rules are a file
177//! ([`Rules::from_json`]), the condition is a [`Trigger`](pamoja_kit::Trigger) with
178//! hysteresis so it fires once per crossing, and a [`RuleEngine`] runs the file off any
179//! link that can receive:
180//!
181//! ```
182//! use pamoja_codec::JsonCodec;
183//! use pamoja_core::{Actuator, Result, Transport};
184//! use pamoja_kit::Edge;
185//! use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
186//! use pamoja_profile::{RuleEngine, Rules};
187//!
188//! struct Valve(bool);
189//! impl Actuator for Valve {
190//!     type Command = bool;
191//!     async fn apply(&mut self, open: bool) -> Result<()> {
192//!         self.0 = open;
193//!         Ok(())
194//!     }
195//! }
196//!
197//! # async fn run() -> Result<()> {
198//! let rules = Rules::from_json(r#"{ "rules": [ {
199//!     "name": "water-when-dry",
200//!     "when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
201//!     "then": [ { "drive": "bed-valve", "on": true } ],
202//!     "otherwise": [ { "drive": "bed-valve", "on": false } ]
203//! } ] }"#)?;
204//!
205//! let broker = LoopbackBroker::new();
206//! let mut probe = LoopbackTransport::new(broker.clone());
207//! probe.connect().await?;
208//! let mut engine = RuleEngine::new(rules, LoopbackTransport::new(broker), JsonCodec)
209//!     .with_actuator("bed-valve", Valve(false));
210//! engine.connect().await?;
211//!
212//! probe.send("garden/bed-1/moisture", b"28.0").await?;
213//! let fired = engine.step().await?.expect("the link is up");
214//! assert_eq!((fired[0].rule.as_str(), fired[0].edge), ("water-when-dry", Edge::Set));
215//! # Ok(())
216//! # }
217//! ```
218//!
219//! A program that moves its own messages hands each reading to a [`RuleEvaluator`]
220//! instead, which is the engine's deciding half on its own: it says which rules set or
221//! cleared and what each calls for, and the program carries it out.
222//!
223//! # Power: sampling that follows the battery
224//!
225//! A [`PowerSchedule`] sets how often a node samples as its battery drains - often when
226//! healthy, sparingly when low - and eases back toward the active cadence while charging.
227//! [`Node::schedule`] turns it into the power mode and the interval to wait before the
228//! next [`tick`](Node::tick).
229//!
230//! # Custom dashboard elements
231//!
232//! The local-first dashboard (the `pamoja-dashboard` crate) draws a built-in set of sensor
233//! types. When a deployment measures something beyond it, the profile *declares* the extra
234//! as a [`Presentation`], and the dashboard renders it with no page change. Each
235//! [`ElementSpec`] names a stable key and unit, the graphic to draw it with ([`Viz`]), an
236//! optional safe band, a label (with optional per-locale labels), whether it is a node
237//! stat, and which groups it is offered on ([`Scope`]). A [`Theme`] tints the console, and
238//! [`with_message`](Presentation::with_message) localizes any custom state or event code
239//! the profile emits.
240//!
241//! The full set of graphics is [`Viz::ALL`], fifteen hand-drawn instruments: a
242//! [`Spark`](Viz::Spark)line, a 270-degree [`Gauge`](Viz::Gauge), a needle
243//! [`Dial`](Viz::Dial), a [`Bar`](Viz::Bar), a [`Thermometer`](Viz::Thermometer), a
244//! [`Droplet`](Viz::Droplet), a [`Battery`](Viz::Battery), a [`Wind`](Viz::Wind) rotor, a
245//! [`Sun`](Viz::Sun), an acoustic [`Wave`](Viz::Wave), a [`Switch`](Viz::Switch) chip, a
246//! [`Valve`](Viz::Valve), a hash [`Chain`](Viz::Chain), a [`Mesh`](Viz::Mesh) map, and a
247//! [`Count`](Viz::Count). Each renders to a stable kind ([`Viz::kind`]) the page draws.
248//!
249//! ```
250//! use pamoja_profile::{ElementSpec, Presentation, Profile, Scope, Theme, Viz};
251//!
252//! // `with_presentation` sets the whole presentation, so on a shipped preset it replaces
253//! // the elements the preset came with. `Profile::with_element` adds one and keeps them.
254//! let profile = Profile::well_level().with_presentation(
255//!     Presentation::new()
256//!         // A turbidity probe drawn as a gauge. WHO drinking-water turbidity stays under 5 NTU.
257//!         .with_element(
258//!             ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
259//!                 .with_band(0.0, 5.0)
260//!                 .with_locale_label("fr", "Turbidité"),
261//!         )
262//!         // A node stat (telemetry about the node itself), offered only on mesh links.
263//!         .with_element(
264//!             ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
265//!                 .as_stat()
266//!                 .on(Scope::Links(vec!["mesh".to_owned()])),
267//!         )
268//!         // Words for a custom state the profile emits, and a brand accent.
269//!         .with_message("state.flushing", "Flushing")
270//!         .with_theme(Theme { accent: Some("#3fb1c8".to_owned()), ..Theme::default() }),
271//! );
272//!
273//! let elements = &profile.presentation.as_ref().unwrap().elements;
274//! assert_eq!(elements.len(), 2); // the preset's own two are replaced, not added to
275//! assert_eq!(elements[0].viz.kind(), "radial"); // Gauge draws as the radial arch
276//! assert_eq!(Viz::ALL.len(), 15);             // fifteen graphics to choose from
277//! ```
278//!
279//! # Show it on a dashboard, wire it to your project
280//!
281//! The dashboard side lives in the `pamoja-dashboard` crate: build a catalog from your
282//! profiles and serve it, gate which sensors a client may add, and feed live readings into
283//! the graphic a profile chose. This is the whole loop (its `examples/gateway.rs` is a
284//! runnable version):
285//!
286//! ```text
287//! use pamoja_dashboard::{Assets, Catalog, Fleet, LinkKind, Reading, Sensor, Server, Viz};
288//!
289//! let fleet = Fleet::builder()
290//!     .org("farm", "Pamoja farm")
291//!     .group("farm", "field", "Field node", LinkKind::Lora)
292//!     .sensor("field", Sensor::new("turbidity",
293//!         Reading::new("water_turbidity", 2.4, "ntu").with_band(0.0, 5.0).with_viz(Viz::Gauge)))
294//!     .build();
295//!
296//! // A real device only accepts the sensors it can bind; anything else is refused.
297//! fleet.allow_sensors(["water_turbidity", "drip_valve"]);
298//!
299//! Server::new(fleet, Assets::Embedded)
300//!     .with_catalog(Catalog::from_profiles(&[&profile])) // served at GET /catalog
301//!     .run("0.0.0.0:80")
302//!     .unwrap();
303//! ```
304//!
305//! From your own sampling loop you push each real reading in with `report_reading`, and the
306//! dashboard reads it; control actions queue back for you to apply. See the
307//! `pamoja-dashboard` crate for the full push model, pairing, and the served catalog.
308//!
309//! # Which pieces do I need?
310//!
311//! - **Just want it to work?** Pick a preset and call [`controller`](Profile::controller).
312//! - **Sharing a recipe?** Write a JSON manifest and load it with [`Profile::from_json`].
313//! - **A rule of your own?** Implement [`Policy`] and run it with [`Node::with_policy`];
314//!   name it in a manifest and resolve it with a [`PolicyRegistry`].
315//! - **One node acting on another's reading?** Write a [`Rules`] file and run it with a
316//!   [`RuleEngine`], or judge readings with a [`RuleEvaluator`] from a loop of your own.
317//! - **A sensor we do not draw?** Add an [`ElementSpec`] with the [`Viz`] you want.
318//! - **Your own look and words?** Add a [`Theme`] and
319//!   [`with_message`](Presentation::with_message) for custom states and events.
320
321// The public traits this crate composes use `async fn`, matching the core SDK.
322#![allow(async_fn_in_trait)]
323
324mod control;
325mod format;
326mod node;
327mod params;
328mod presentation;
329mod profile;
330mod rules;
331pub mod wiring;
332
333pub use control::{Alert, BoxedPolicy, Controller, Policy, PolicyRegistry, Reaction};
334pub use format::FORMAT;
335pub use node::{NoActuator, Node, Tick};
336pub use params::{Param, Params};
337pub use presentation::{ElementSpec, LocalizedText, Presentation, Scope, Theme, Viz};
338pub use profile::{ControlSpec, PowerSchedule, Profile, Reads};
339pub use rules::{Action, Compare, Condition, Fired, Rule, RuleEngine, RuleEvaluator, Rules};