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`], and an optional [`Presentation`]. It
16//!   serializes to and from JSON with [`Profile::to_json`] and [`Profile::from_json`], so
17//!   a profile ships as a file and loads onto a device. The presets
18//!   [`Profile::vaccine_fridge_monitor`], [`Profile::irrigation_node`],
19//!   [`Profile::well_level`], and [`Profile::flood_sensor`] are convenience constructors
20//!   for the same data.
21//! - [`Node`] is what the runtime assembles from a profile and real components: a
22//!   [`Sensor`](pamoja_core::Sensor), an [`Actuator`](pamoja_core::Actuator), a
23//!   [`Transport`](pamoja_core::Transport), and a [`Codec`](pamoja_codec::Codec). Each
24//!   [`tick`](Node::tick) reads, decides, drives the output, and publishes.
25//!
26//! The decision logic is a [`Controller`] that composes the `pamoja-kit` helpers, so a
27//! profile is glue over field-tested math rather than new behavior. Its I/O is async; its
28//! decisions are synchronous and hardware-free, so a whole control policy is unit-testable
29//! with no devices and no network.
30//!
31//! # Start simple: pick a preset
32//!
33//! The quickest path is a named preset. Hand its controller a reading and it decides:
34//!
35//! ```
36//! use pamoja_profile::{Alert, Profile};
37//!
38//! let mut control = Profile::vaccine_fridge_monitor().controller();
39//!
40//! // A warm fridge: the cooler runs and a spoilage excursion is flagged.
41//! let reaction = control.evaluate(9.0);
42//! assert_eq!(reaction.actuator, Some(true));
43//! assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
44//! ```
45//!
46//! # The manifest: write it, share it, load it
47//!
48//! A profile is just data, so a community can write one as JSON, store it in a file, and
49//! share it - no code. [`Profile::from_json`] loads it and [`Profile::to_json`] writes it
50//! back; the power thresholds are optional and default when omitted.
51//!
52//! ```
53//! use pamoja_profile::Profile;
54//!
55//! let manifest = r#"{
56//!     "name": "rain-tank",
57//!     "topic": "water/tank/level",
58//!     "control": { "kind": "level", "empty": 0.0, "warn_within": 5 },
59//!     "power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
60//! }"#;
61//!
62//! let profile = Profile::from_json(manifest).expect("a valid manifest");
63//! assert_eq!(profile.name, "rain-tank");
64//! assert!(profile.to_json().unwrap().contains("rain-tank"));
65//! ```
66//!
67//! # Control policies
68//!
69//! Every profile names one [`ControlSpec`], the rule applied to each reading:
70//!
71//! - `Setpoint` holds a value by switching an output on and off (a fridge's cooler, an
72//!   irrigation valve) and alerts when the reading leaves a safe band.
73//! - `Level` watches a falling level and warns before it reaches empty.
74//! - `Surge` warns when a reading changes faster than a safe rate (a flash flood).
75//! - `Monitor` only reports, with no output and no alert.
76//!
77//! Every field is public, so a deployment can build or tune a policy in place:
78//!
79//! ```
80//! use pamoja_profile::{ControlSpec, PowerSchedule, Profile};
81//!
82//! // Hold soil moisture near 35% by opening a valve - a "heater" for moisture.
83//! let profile = Profile {
84//!     name: "drip-node".to_owned(),
85//!     topic: "farm/soil-moisture".to_owned(),
86//!     control: ControlSpec::Setpoint { setpoint: 35.0, hysteresis: 5.0, cooling: false, safe_band: 25.0 },
87//!     power: PowerSchedule::new(300, 1800, 3600),
88//!     presentation: None,
89//! };
90//! let mut control = profile.controller();
91//! assert_eq!(control.evaluate(28.0).actuator, Some(true)); // dry: the valve opens
92//! ```
93//!
94//! # Power: sampling that follows the battery
95//!
96//! A [`PowerSchedule`] sets how often a node samples as its battery drains - often when
97//! healthy, sparingly when low - and eases back toward the active cadence while charging.
98//! [`Node::schedule`] turns it into the power mode and the interval to wait before the
99//! next [`tick`](Node::tick).
100//!
101//! # Custom dashboard elements
102//!
103//! The local-first dashboard (the `pamoja-dashboard` crate) draws a built-in set of sensor
104//! types. When a deployment measures something beyond it, the profile *declares* the extra
105//! as a [`Presentation`], and the dashboard renders it with no page change. Each
106//! [`ElementSpec`] names a stable key and unit, the graphic to draw it with ([`Viz`]), an
107//! optional safe band, a label (with optional per-locale labels), whether it is a node
108//! stat, and which groups it is offered on ([`Scope`]). A [`Theme`] tints the console, and
109//! [`with_message`](Presentation::with_message) localizes any custom state or event code
110//! the profile emits.
111//!
112//! The full set of graphics is [`Viz::ALL`], fifteen hand-drawn instruments: a
113//! [`Spark`](Viz::Spark)line, a 270-degree [`Gauge`](Viz::Gauge), a needle
114//! [`Dial`](Viz::Dial), a [`Bar`](Viz::Bar), a [`Thermometer`](Viz::Thermometer), a
115//! [`Droplet`](Viz::Droplet), a [`Battery`](Viz::Battery), a [`Wind`](Viz::Wind) rotor, a
116//! [`Sun`](Viz::Sun), an acoustic [`Wave`](Viz::Wave), a [`Switch`](Viz::Switch) chip, a
117//! [`Valve`](Viz::Valve), a hash [`Chain`](Viz::Chain), a [`Mesh`](Viz::Mesh) map, and a
118//! [`Count`](Viz::Count). Each renders to a stable kind ([`Viz::kind`]) the page draws.
119//!
120//! ```
121//! use pamoja_profile::{ElementSpec, Presentation, Profile, Scope, Theme, Viz};
122//!
123//! let profile = Profile::well_level().with_presentation(
124//!     Presentation::new()
125//!         // A turbidity probe drawn as a gauge. WHO drinking-water turbidity stays under 5 NTU.
126//!         .with_element(
127//!             ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
128//!                 .with_band(0.0, 5.0)
129//!                 .with_locale_label("fr", "Turbidité"),
130//!         )
131//!         // A node stat (telemetry about the node itself), offered only on mesh links.
132//!         .with_element(
133//!             ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
134//!                 .as_stat()
135//!                 .on(Scope::Links(vec!["mesh".to_owned()])),
136//!         )
137//!         // Words for a custom state the profile emits, and a brand accent.
138//!         .with_message("state.flushing", "Flushing")
139//!         .with_theme(Theme { accent: Some("#3fb1c8".to_owned()), ..Theme::default() }),
140//! );
141//!
142//! let turbidity = &profile.presentation.as_ref().unwrap().elements[0];
143//! assert_eq!(turbidity.viz.kind(), "radial"); // Gauge draws as the radial arch
144//! assert_eq!(Viz::ALL.len(), 15);             // fifteen graphics to choose from
145//! ```
146//!
147//! # Show it on a dashboard, wire it to your project
148//!
149//! The dashboard side lives in the `pamoja-dashboard` crate: build a catalog from your
150//! profiles and serve it, gate which sensors a client may add, and feed live readings into
151//! the graphic a profile chose. This is the whole loop (its `examples/gateway.rs` is a
152//! runnable version):
153//!
154//! ```text
155//! use pamoja_dashboard::{Assets, Catalog, Fleet, LinkKind, Reading, Sensor, Server, Viz};
156//!
157//! let fleet = Fleet::builder()
158//!     .org("farm", "Pamoja farm")
159//!     .group("farm", "field", "Field node", LinkKind::Lora)
160//!     .sensor("field", Sensor::new("turbidity",
161//!         Reading::new("water_turbidity", 2.4, "ntu").with_band(0.0, 5.0).with_viz(Viz::Gauge)))
162//!     .build();
163//!
164//! // A real device only accepts the sensors it can bind; anything else is refused.
165//! fleet.allow_sensors(["water_turbidity", "drip_valve"]);
166//!
167//! Server::new(fleet, Assets::Embedded)
168//!     .with_catalog(Catalog::from_profiles(&[&profile])) // served at GET /catalog
169//!     .run("0.0.0.0:80")
170//!     .unwrap();
171//! ```
172//!
173//! From your own sampling loop you push each real reading in with `report_reading`, and the
174//! dashboard reads it; control actions queue back for you to apply. See the
175//! `pamoja-dashboard` crate for the full push model, pairing, and the served catalog.
176//!
177//! # Which pieces do I need?
178//!
179//! - **Just want it to work?** Pick a preset and call [`controller`](Profile::controller).
180//! - **Sharing a recipe?** Write a JSON manifest and load it with [`Profile::from_json`].
181//! - **A sensor we do not draw?** Add an [`ElementSpec`] with the [`Viz`] you want.
182//! - **Your own look and words?** Add a [`Theme`] and
183//!   [`with_message`](Presentation::with_message) for custom states and events.
184
185// The public traits this crate composes use `async fn`, matching the core SDK.
186#![allow(async_fn_in_trait)]
187
188mod control;
189mod node;
190mod presentation;
191mod profile;
192
193pub use control::{Alert, Controller, Reaction};
194pub use node::{NoActuator, Node};
195pub use presentation::{ElementSpec, LocalizedText, Presentation, Scope, Theme, Viz};
196pub use profile::{ControlSpec, PowerSchedule, Profile};