Expand description
Device profiles: named, ready-to-run nodes assembled from pamoja capabilities.
Most people who can put a sensor to good use are not electrical engineers, and the gap between “I can read a sensor” and “I built something that works and warns me when it fails” is wiring, tuning, and glue code. A device profile closes that gap. It is a named, pre-wired bundle - a control policy, a publish topic, and a power schedule - that a builder instantiates instead of choosing algorithms and constants by hand.
This guide runs from the simplest use to a fully themed dashboard. Skip to Which pieces do I need? for a one-line map.
§The shape of a profile
Profileis the manifest: plain data a community can publish and share, carrying aControlSpec, aPowerSchedule, an optionalPresentation, and a description of what it is for. It serializes to and from JSON withProfile::to_jsonandProfile::from_json, so a profile ships as a file and loads onto a device. The presetsProfile::vaccine_fridge_monitor,Profile::irrigation_node,Profile::well_level, andProfile::flood_sensorare convenience constructors for the same data.Nodeis what the runtime assembles from a profile and real components: aSensor, anActuator, aTransport, and aCodec. Eachtickreads, decides, drives the output, and publishes.wiring::Wiringis one site’s wiring file: the part that takes the readings, the GPIO line the output drives, the link, and the battery. With therunnerfeature, thepamoja-nodebinary reads it beside a profile and runs the node with no program to write.
The decision logic is a Controller that composes the pamoja-kit helpers, so a
profile is glue over field-tested math rather than new behavior. Its I/O is async; its
decisions are synchronous and hardware-free, so a whole control policy is unit-testable
with no devices and no network.
§Start simple: pick a preset
The quickest path is a named preset. Hand its controller a reading and it decides:
use pamoja_profile::{Alert, Profile};
let mut control = Profile::vaccine_fridge_monitor().controller()?;
// A warm fridge: the cooler runs and a spoilage excursion is flagged.
let reaction = control.evaluate(9.0);
assert_eq!(reaction.actuator, Some(true));
assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));§The manifest: write it, share it, load it
A profile is just data, so a community can write one as JSON, store it in a file, and
share it - no code. Profile::from_json loads it and Profile::to_json writes it
back; the power thresholds are optional and default when omitted. The $schema line
names the format the file is written in (FORMAT), which an editor reads to check
the file as it is typed, and reads says what the node measures. A field the format
does not have is refused, with the one it was probably meant to be.
use pamoja_profile::Profile;
let manifest = r#"{
"$schema": "https://pamoja.molex.cloud/schema/profile-1.json",
"name": "rain-tank",
"reads": { "quantity": "water_level", "unit": "meter" },
"topic": "water/tank/level",
"control": { "kind": "level", "empty": 0.0, "warn_within": 5 },
"power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
}"#;
let profile = Profile::from_json(manifest).expect("a valid manifest");
assert_eq!(profile.name, "rain-tank");
assert!(profile.to_json().unwrap().contains("rain-tank"));§Control policies
Every profile names one ControlSpec, the rule applied to each reading:
Setpointholds a value by switching an output on and off (a fridge’s cooler, an irrigation valve) and alerts when the reading leaves a safe band.Levelwatches a falling level and warns before it reaches empty.Surgewarns when a reading changes faster than a safe rate (a flash flood).Monitoronly reports, with no output and no alert.- Any other
kindis a custom policy: its parameters ride beside it in the manifest, and aPolicyRegistryresolves the kind to your code.
Every field is public, so a deployment can build or tune a policy in place:
use pamoja_profile::{ControlSpec, PowerSchedule, Profile};
// Hold soil moisture near 35% by opening a valve - a "heater" for moisture.
let profile = Profile {
name: "drip-node".to_owned(),
description: None,
reads: None,
topic: "farm/soil-moisture".to_owned(),
control: ControlSpec::Setpoint { setpoint: 35.0, hysteresis: 5.0, cooling: false, safe_band: 25.0 },
power: PowerSchedule::new(300, 1800, 3600),
presentation: None,
};
let mut control = profile.controller()?;
assert_eq!(control.evaluate(28.0).actuator, Some(true)); // dry: the valve opens§Your own policy
The built-in kinds decide one number into an on/off setting. A Policy of your own
decides whatever a driver reads into whatever an actuator takes, raises its own
Alert::Custom conditions, and runs in the same node through
Node::with_policy:
use pamoja_profile::{Alert, Policy, Reaction};
// Frost settles when the air is cold and damp, so this reads both at once.
struct FrostGuard {
warn_below: f32,
}
impl Policy for FrostGuard {
type Reading = (f32, f32);
type Command = bool;
fn evaluate(&mut self, &(temperature, humidity): &(f32, f32)) -> Reaction {
let risk = temperature < self.warn_below && humidity > 80.0;
Reaction {
actuator: Some(risk),
alert: risk.then_some(Alert::Custom { code: "FrostRisk", value: temperature }),
}
}
}
let mut guard = FrostGuard { warn_below: 2.0 };
assert_eq!(guard.evaluate(&(1.0, 92.0)).alert.map(Alert::kind), Some("FrostRisk"));§A kind the library never shipped
A manifest may name a control kind of your own, with its parameters beside it, and
a PolicyRegistry turns that kind into the policy that decides it, so a policy
written once ships as data like every other:
use pamoja_core::Error;
use pamoja_profile::{BoxedPolicy, Controller, PolicyRegistry, Profile};
let manifest = r#"{
"name": "orchard-frost",
"topic": "orchard/air/temperature",
"control": { "kind": "frost_guard", "warn_below": 2.0 },
"power": { "active_secs": 60, "saver_secs": 300, "critical_secs": 900 }
}"#;
let profile = Profile::from_json(manifest)?;
// Here the kind reuses a built-in controller as its policy; any `Policy` will do.
let registry = PolicyRegistry::new().register("frost_guard", |params| {
let warn_below = params
.number("warn_below")
.ok_or(Error::Unsupported("frost_guard needs `warn_below`"))?;
Ok(Box::new(Controller::setpoint(warn_below as f32, 0.5, false, 5.0)) as BoxedPolicy)
});
let mut policy = registry.resolve(&profile.control)?;
assert_eq!(policy.evaluate(&1.0).actuator, Some(true)); // cold: the heater comes on§Rules between nodes
A profile decides at the node that reads. A Rule decides between nodes: it watches
one topic for a reading crossing a line and, the moment it does, drives an actuator
the engine holds by name or publishes a message, over the same link. Rules are a file
(Rules::from_json), the condition is a Trigger with
hysteresis so it fires once per crossing, and a RuleEngine runs the file off any
link that can receive:
use pamoja_codec::JsonCodec;
use pamoja_core::{Actuator, Result, Transport};
use pamoja_kit::Edge;
use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
use pamoja_profile::{RuleEngine, Rules};
struct Valve(bool);
impl Actuator for Valve {
type Command = bool;
async fn apply(&mut self, open: bool) -> Result<()> {
self.0 = open;
Ok(())
}
}
let rules = Rules::from_json(r#"{ "rules": [ {
"name": "water-when-dry",
"when": { "topic": "garden/bed-1/moisture", "below": 30.0, "hysteresis": 5.0 },
"then": [ { "drive": "bed-valve", "on": true } ],
"otherwise": [ { "drive": "bed-valve", "on": false } ]
} ] }"#)?;
let broker = LoopbackBroker::new();
let mut probe = LoopbackTransport::new(broker.clone());
probe.connect().await?;
let mut engine = RuleEngine::new(rules, LoopbackTransport::new(broker), JsonCodec)
.with_actuator("bed-valve", Valve(false));
engine.connect().await?;
probe.send("garden/bed-1/moisture", b"28.0").await?;
let fired = engine.step().await?.expect("the link is up");
assert_eq!((fired[0].rule.as_str(), fired[0].edge), ("water-when-dry", Edge::Set));A program that moves its own messages hands each reading to a RuleEvaluator
instead, which is the engine’s deciding half on its own: it says which rules set or
cleared and what each calls for, and the program carries it out.
§Power: sampling that follows the battery
A PowerSchedule sets how often a node samples as its battery drains - often when
healthy, sparingly when low - and eases back toward the active cadence while charging.
Node::schedule turns it into the power mode and the interval to wait before the
next tick.
§Custom dashboard elements
The local-first dashboard (the pamoja-dashboard crate) draws a built-in set of sensor
types. When a deployment measures something beyond it, the profile declares the extra
as a Presentation, and the dashboard renders it with no page change. Each
ElementSpec names a stable key and unit, the graphic to draw it with (Viz), an
optional safe band, a label (with optional per-locale labels), whether it is a node
stat, and which groups it is offered on (Scope). A Theme tints the console, and
with_message localizes any custom state or event code
the profile emits.
The full set of graphics is Viz::ALL, fifteen hand-drawn instruments: a
Sparkline, a 270-degree Gauge, a needle
Dial, a Bar, a Thermometer, a
Droplet, a Battery, a Wind rotor, a
Sun, an acoustic Wave, a Switch chip, a
Valve, a hash Chain, a Mesh map, and a
Count. Each renders to a stable kind (Viz::kind) the page draws.
use pamoja_profile::{ElementSpec, Presentation, Profile, Scope, Theme, Viz};
// `with_presentation` sets the whole presentation, so on a shipped preset it replaces
// the elements the preset came with. `Profile::with_element` adds one and keeps them.
let profile = Profile::well_level().with_presentation(
Presentation::new()
// A turbidity probe drawn as a gauge. WHO drinking-water turbidity stays under 5 NTU.
.with_element(
ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
.with_band(0.0, 5.0)
.with_locale_label("fr", "Turbidité"),
)
// A node stat (telemetry about the node itself), offered only on mesh links.
.with_element(
ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
.as_stat()
.on(Scope::Links(vec!["mesh".to_owned()])),
)
// Words for a custom state the profile emits, and a brand accent.
.with_message("state.flushing", "Flushing")
.with_theme(Theme { accent: Some("#3fb1c8".to_owned()), ..Theme::default() }),
);
let elements = &profile.presentation.as_ref().unwrap().elements;
assert_eq!(elements.len(), 2); // the preset's own two are replaced, not added to
assert_eq!(elements[0].viz.kind(), "radial"); // Gauge draws as the radial arch
assert_eq!(Viz::ALL.len(), 15); // fifteen graphics to choose from§Show it on a dashboard, wire it to your project
The dashboard side lives in the pamoja-dashboard crate: build a catalog from your
profiles and serve it, gate which sensors a client may add, and feed live readings into
the graphic a profile chose. This is the whole loop (its examples/gateway.rs is a
runnable version):
use pamoja_dashboard::{Assets, Catalog, Fleet, LinkKind, Reading, Sensor, Server, Viz};
let fleet = Fleet::builder()
.org("farm", "Pamoja farm")
.group("farm", "field", "Field node", LinkKind::Lora)
.sensor("field", Sensor::new("turbidity",
Reading::new("water_turbidity", 2.4, "ntu").with_band(0.0, 5.0).with_viz(Viz::Gauge)))
.build();
// A real device only accepts the sensors it can bind; anything else is refused.
fleet.allow_sensors(["water_turbidity", "drip_valve"]);
Server::new(fleet, Assets::Embedded)
.with_catalog(Catalog::from_profiles(&[&profile])) // served at GET /catalog
.run("0.0.0.0:80")
.unwrap();From your own sampling loop you push each real reading in with report_reading, and the
dashboard reads it; control actions queue back for you to apply. See the
pamoja-dashboard crate for the full push model, pairing, and the served catalog.
§Which pieces do I need?
- Just want it to work? Pick a preset and call
controller. - Sharing a recipe? Write a JSON manifest and load it with
Profile::from_json. - A rule of your own? Implement
Policyand run it withNode::with_policy; name it in a manifest and resolve it with aPolicyRegistry. - One node acting on another’s reading? Write a
Rulesfile and run it with aRuleEngine, or judge readings with aRuleEvaluatorfrom a loop of your own. - A sensor we do not draw? Add an
ElementSpecwith theVizyou want. - Your own look and words? Add a
Themeandwith_messagefor custom states and events.
Modules§
- wiring
- A wiring file: which part reads what a profile reads, which line its output drives, and which link its readings go over, for one site.
Structs§
- Condition
- A line drawn through one topic’s readings, with the release band that stops it firing over and over.
- Controller
- The assembled, stateful decision logic of a profile’s built-in kind.
- Element
Spec - A custom sensor or node stat a profile contributes to the dashboard.
- Fired
- What a rule did on one message.
- NoActuator
- An actuator that accepts and ignores commands.
- Node
- A profile assembled around the components that make it run.
- Params
- The parameters of a custom control kind, by name.
- Policy
Registry - What resolves a manifest’s control kind to the code that decides it.
- Power
Schedule - How often a node samples as its battery drains, in plain seconds.
- Presentation
- How a profile presents itself on the dashboard: its custom elements and theme.
- Profile
- A named, pre-wired bundle of control policy, publish topic, and power schedule.
- Reaction
- The outcome of evaluating one reading against a policy.
- Reads
- What a profile reads: the quantity its control decides on, and the unit its numbers are in.
- Rule
- One rule: a condition over a topic, and what to do as it sets and clears.
- Rule
Engine - Runs a set of rules off a link: every reading that arrives on a watched topic is judged, and the actions a set or cleared condition calls for are carried out.
- Rule
Evaluator - The decisions a set of rules makes, reading by reading, with no link and no outputs of its own.
- Rules
- A set of rules, as a file a fleet shares.
- Theme
- A small set of theme tokens a profile can set on the dashboard.
- Tick
- One reading a node took, and what its policy decided about it.
Enums§
- Action
- What a rule does when its condition sets or clears.
- Alert
- An alert raised when a reading crosses a profile’s safety threshold.
- Compare
- Which side of a line a reading must be on for a condition to hold.
- Control
Spec - How a profile turns each reading into control output and alerts.
- Localized
Text - A piece of human-facing text a profile supplies, either one string for every locale or a per-locale map.
- Param
- One parameter of a custom control kind: a number, a flag, or a piece of text.
- Scope
- Which groups a declared element is offered on when a user adds a sensor.
- Viz
- The graphic a reading is drawn with on the dashboard.
Constants§
- FORMAT
- The version of the profile manifest and rule file formats this build reads and writes.
Traits§
- Policy
- The decision half of a node: what one reading calls for.
Type Aliases§
- Boxed
Policy - A policy behind a trait object, as a
PolicyRegistryhands one out.