Device profiles#
A profile is a node written down as a file: what it reads, where it reports, how it decides each reading, how often it samples as its battery drains, and how a dashboard draws it. Retuning a deadband across a hundred nodes is then a file that ships, not a firmware build, and the device and the dashboard read the same file.
Data and code#
A profile carries no code. It picks code that already exists and tunes it:
| Part of a node | Where it lives |
|---|---|
| The target, deadband, alert range, and sampling intervals | the profile |
| Which policy decides each reading | the profile, as its control kind |
The policies setpoint, level, surge, and monitor | compiled into the library |
| A policy the library does not ship | your program, registered under the kind's name |
| The probe, the output, and the link | your program, or a wiring file for the stock runner |
That split is what makes a profile safe to share and to fetch. The worst a file can do is set a number, and every file is checked as it loads: a value no node could run, or a field the format does not have, is refused with the reason.
Where a profile comes from#
- A file on disk. The example below loads
profiles/brooder-heater.jsonfrom the catalog. - A link. A gateway publishes a profile on an MQTT topic, or serves it as the body of an HTTP response. It signs what it sends, and the node checks the signature before it reads a byte, as the fourth part of the example shows.
- The stock runner.
pamoja-nodereads a profile and a site's wiring file and runs the node, with no program to write.
What the example does#
It runs the brooder profile as a node over a morning of five temperatures around its 32 C target, where the lamp switches on at 31.5 C or below and off at 32.5 C or above. A list stands in for the probe, a variable for the heat lamp, and an in-process broker for the link, with a dashboard listening. Then it runs the other built-in policies on a well and a river, a control kind of the program's own, a profile that arrives signed over a link, and what goes wrong.
It proves:
- The file parses into the profile a node runs: what it reads, the topic it reports on, the setpoint policy, the sampling schedule, and how a dashboard draws it.
- The node holds the lamp through the deadband in both directions, raises
OutOfRangefor a reading more than 4 C from the target, and publishes every reading, in order, to the topic the file names. - At a charge of 80% the node samples every 2 minutes, at 30% every 10, and at 10% every 30, the cadences the file sets.
- A level warns once a fall puts dry within six samples, and a surge warns when one sample rises more than its limit.
- A kind the library never shipped runs the program's own code once it is registered under the kind's name, reading its parameters from the file and raising a condition of its own.
- A profile the gateway signed arrives over a link and loads, and one signed by any other key is refused before it is read.
- A reading that is not a number raises
InvalidReadingand the lamp holds, and a controller built again for each reading forgets the lamp was on. - A manifest with a hysteresis of zero, or intervals that shorten as the battery drains, is refused with the reason, and one with a misspelled field is refused with the field it was probably meant to be and where it sits. A custom kind with no code registered for it is refused rather than run as a node that never switches the lamp.
Run it#
The example below is a program CI runs on every change, in each language, from a clone of the repository:
Rust
cargo run -p pamoja-examples --example profileTypeScript
npm --prefix bindings/node run guides -- profilePython
python bindings/python/guides/profile.pyC#
dotnet run --project bindings/dotnet/samples/Pamoja.Guides -- profileRust#
In Rust, Profile::from_json loads a manifest and checks it, returning an Err
holding the reason for one no node could run. Node::new assembles the profile with
a Sensor, an Actuator, a Transport, and a Codec; each tick returns a Tick
with the reading and the Reaction, the output setting and an Alert, and run
repeats it at the cadence schedule gives, sleeping with the runtime's own timer. A
policy of your own implements Policy, and Node::resolve runs the one a
PolicyRegistry finds for a manifest's custom kind.
From examples/guides/profile.rs:
use pamoja_codec::{Codec, JsonCodec};
use pamoja_core::{Receive, Transport};
use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
use pamoja_profile::{Node, Profile};
// A profile is a file. This one ships in the catalog under profiles/: it holds a
// brooder at 32 C by switching a heat lamp, says what it reads, and says how a
// dashboard draws it.
let text = std::fs::read_to_string("profiles/brooder-heater.json")?;
let profile = Profile::from_json(&text)?;
let reads = profile
.reads
.clone()
.expect("a catalog profile says what it reads");
println!(
"profile {} reads {} in {} and reports on {}",
profile.name, reads.quantity, reads.unit, profile.topic
);
let topic = profile.topic.clone();
let element = profile
.presentation
.clone()
.expect("a presentation")
.elements[0]
.clone();
// A node is the profile and the parts that make it run: a sensor, an output, and a
// link. A morning of readings stands in for the probe, and a dashboard listens on the
// same broker.
let broker = LoopbackBroker::new();
let mut link = LoopbackTransport::new(broker.clone());
let mut dashboard = LoopbackTransport::new(broker);
link.connect().await?;
dashboard.connect().await?;
dashboard.subscribe(&topic).await?;
let morning = Morning(vec![27.5, 31.8, 32.6, 32.1, 31.4]);
let mut node = Node::new(profile, morning, Lamp::default(), link, JsonCodec)?;
// Each tick reads, decides, switches the lamp, and publishes the reading. The lamp
// comes on at 31.5 C or below and goes off at 32.5 C or above, and in between it stays
// as it was; a reading more than 4 C from 32 raises an alert as well.
let mut was = false;
for _ in 0..5 {
let tick = node.tick().await?;
let on = tick.reaction.actuator == Some(true);
let change = match (was, on) {
(false, true) => "lamp on",
(true, false) => "lamp off",
(true, true) => "lamp stays on",
(false, false) => "lamp stays off",
};
let alert = tick
.reaction
.alert
.map(|alert| format!(", alert {}", alert.kind()))
.unwrap_or_default();
println!("{:<10}{change}{alert}", format!("{} C", tick.reading));
was = on;
}
// The dashboard heard every reading the node published.
let mut heard = Vec::new();
for _ in 0..5 {
let message = dashboard.recv().await?.expect("a reading");
let reading: f32 = JsonCodec.decode(&message.payload)?;
heard.push(reading.to_string());
}
println!("heard {} on {topic}", heard.join(", "));
// Between ticks the node waits as long as its battery allows: often on a healthy
// charge, sparingly on a low one. run() does this until stopped, waiting each interval.
for charge in [0.8, 0.3, 0.1] {
let (mode, wait) = node.schedule(charge, false);
println!(
"battery at {:.0}% it runs {mode:?} and waits {} s",
charge * 100.0,
wait.as_secs()
);
}
// The same file says how a dashboard draws the node.
let [low, high] = element.band.expect("a band");
println!(
"draws {} in {} on a {}, safe from {low} to {high}",
element.key,
element.unit,
element.viz.name()
);The other policies, continuing from above:
From examples/guides/profile.rs:
use pamoja_profile::Alert;
// A level warns before a tank or a well runs dry. The shipped well profile counts
// 0.5 m as dry and warns once the last fall puts dry six samples away or nearer.
let mut well = Profile::well_level().controller()?;
for depth in [5.0, 4.4, 3.8] {
match well.evaluate(depth).alert {
Some(Alert::RunningOut { samples }) => {
println!("well {depth} m: dry in {samples} samples at this rate, RunningOut")
}
_ => println!("well {depth} m: no warning yet"),
}
}
// A surge warns when a reading moves too far in one sample. The shipped flood sensor
// warns when a river rises more than 0.3 m between two readings.
let mut river = Profile::flood_sensor().controller()?;
for gauge in [1.2, 1.35, 1.9] {
match river.evaluate(gauge).alert {
Some(Alert::ChangingFast { rate }) => {
println!("river {gauge} m: up {rate:.2} m in one sample, ChangingFast")
}
_ => println!("river {gauge} m: no warning"),
}
}A control kind of the program's own, continuing from above:
From examples/guides/profile.rs:
use pamoja_profile::{BoxedPolicy, Params, Policy, PolicyRegistry, Reaction};
/// A policy of the program's own: the lamp on below the setpoint, and a condition of
/// its own when the chicks are chilled.
struct BrooderGuard {
setpoint: f32,
chilled_below: f32,
}
impl Policy for BrooderGuard {
type Reading = f32;
type Command = bool;
fn evaluate(&mut self, reading: &f32) -> Reaction {
let chilled = *reading < self.chilled_below;
Reaction {
actuator: Some(*reading < self.setpoint),
alert: chilled.then_some(Alert::Custom {
code: "Chilled",
value: *reading,
}),
}
}
}
// A manifest may name a control kind the library never shipped, with its parameters
// beside it. The program registers the code that decides it under that name, and the
// node runs whichever kind the file names.
let guarded =
Profile::from_json(&text.replace("\"kind\": \"setpoint\"", "\"kind\": \"brooder_guard\""))?;
let registry = PolicyRegistry::new().register("brooder_guard", |params: &Params| {
let setpoint = params.number("setpoint").unwrap_or(32.0) as f32;
let band = params.number("safe_band").unwrap_or(4.0) as f32;
Ok(Box::new(BrooderGuard {
setpoint,
chilled_below: setpoint - band,
}) as BoxedPolicy)
});
println!(
"custom {} is decided by the program's own code, registered under its name",
guarded.control.kind()
);
let mut link = LoopbackTransport::new(LoopbackBroker::new());
link.connect().await?;
let mut node = Node::resolve(
guarded,
®istry,
Morning(vec![27.5]),
Lamp::default(),
link,
JsonCodec,
)?;
let tick = node.tick().await?;
let lamp = if tick.reaction.actuator == Some(true) {
"on"
} else {
"off"
};
let alert = tick.reaction.alert.map(Alert::kind).unwrap_or("none");
println!(
"{:<10}lamp {lamp}, alert {alert}",
format!("{} C", tick.reading)
);A profile that arrives over a link, continuing from above:
From examples/guides/profile.rs:
use pamoja_security::DeviceIdentity;
// A profile can arrive over a link as well as from a disk: on an MQTT topic the gateway
// publishes to, or as the body of an HTTP response. The gateway signs what it sends and
// each node holds only the gateway's public key, so a profile is checked before it is
// read, and one from anywhere else never runs.
let gateway = DeviceIdentity::from_seed(&[7u8; 32]);
let trusted = gateway.public();
let broker = LoopbackBroker::new();
let mut uplink = LoopbackTransport::new(broker.clone());
let mut downlink = LoopbackTransport::new(broker);
uplink.connect().await?;
downlink.connect().await?;
let fleet = "fleet/brooders/profile";
downlink.subscribe(fleet).await?;
uplink
.send(fleet, &gateway.sign_message(text.as_bytes()))
.await?;
let message = downlink.recv().await?.expect("a profile");
let signed = trusted.verify_message(&message.payload)?;
let delivered = Profile::from_json(std::str::from_utf8(signed)?)?;
println!(
"network {} arrived on {fleet}, signed by the gateway, and loads",
delivered.name
);
let stranger = DeviceIdentity::from_seed(&[9u8; 32]);
uplink
.send(fleet, &stranger.sign_message(text.as_bytes()))
.await?;
let message = downlink.recv().await?.expect("a profile");
if trusted.verify_message(&message.payload).is_err() {
println!("network one signed by any other key is refused before it is read");
}What goes wrong, continuing from above:
From examples/guides/profile.rs:
// A probe that fails reports a reading that is not a number. The controller raises
// it rather than going quiet, and the lamp holds its state; what off means for the
// chicks is the node's call.
let profile = Profile::from_json(&text)?;
let mut controller = profile.controller()?;
controller.evaluate(27.5);
let failed = controller.evaluate(f32::NAN);
if let Some(alert) = failed.alert {
let holds = if failed.actuator == Some(true) {
"on"
} else {
"off"
};
println!(
"probe a reading of NaN raises {}, and the lamp holds {holds}",
alert.kind()
);
}
// A controller built again for each reading forgets the lamp was on, so inside the
// deadband it switches the lamp off.
let first = profile.controller()?.evaluate(27.5).actuator;
let then = profile.controller()?.evaluate(31.8).actuator;
if first == Some(true) && then == Some(false) {
println!(
"fresh built again for each reading, the controller turns the lamp off at 31.8 C"
);
}
// A manifest no node could run is refused as it loads, with the reason. So is a
// misspelled field, with the one it was probably meant to be and where it sits,
// rather than leaving the default in its place without a word.
for edited in [
text.replace("\"hysteresis\": 0.5", "\"hysteresis\": 0.0"),
text.replace("\"saver_secs\": 600", "\"saver_secs\": 60"),
text.replace("\"saver_below\"", "\"saver_bellow\""),
] {
match Profile::from_json(&edited) {
Ok(_) => {
println!("a manifest no node could run was accepted, which should never happen")
}
Err(error) => println!("refused {error}"),
}
}
// A kind the library does not ship loads with its parameters, but no built-in
// controller decides it, so a node without a registry that knows it is refused
// rather than running one that never switches the lamp.
let unknown =
Profile::from_json(&text.replace("\"kind\": \"setpoint\"", "\"kind\": \"brooder_guard\""))?;
match unknown.controller() {
Ok(_) => println!("a custom kind ran without its policy, which should never happen"),
Err(error) => println!("refused {error}"),
}TypeScript#
In TypeScript, @pamoja/profile loads a manifest with Profile.fromJson, which
throws an Error saying why for one no node could run. A Node takes the profile,
a read function, a drive function, and any link with send; tick resolves to
the reading and a reaction whose actuator is a boolean or undefined and whose
alert carries a kind from AlertKind, and run repeats it at the battery's
cadence until its AbortSignal fires. A PolicyRegistry maps a custom kind to a
function that builds the program's own policy.
From bindings/node/guides/profile.ts:
import { readFileSync } from 'node:fs'
import { LoopbackBroker } from '@pamoja/loopback'
import { Node, Profile } from '@pamoja/profile'
// A profile is a file. This one ships in the catalog under profiles/: it holds a brooder at
// 32 C by switching a heat lamp, says what it reads, and says how a dashboard draws it.
const text = readFileSync('profiles/brooder-heater.json', 'utf8')
const profile = Profile.fromJson(text)
async function example(): Promise<boolean> {
const reads = profile.reads!
console.log(`profile ${profile.name} reads ${reads.quantity} in ${reads.unit} and reports on ${profile.topic}`)
// A node is the profile and the parts that make it run: a sensor, an output, and a link.
// A morning of readings stands in for the probe, and a dashboard listens on the same
// broker.
const broker = new LoopbackBroker()
const link = broker.link()
const dashboard = broker.link()
await link.connect()
await dashboard.connect()
await dashboard.subscribe(profile.topic)
const morning = [27.5, 31.8, 32.6, 32.1, 31.4]
let lamp = false
const node = new Node({
profile,
read: () => morning.shift()!,
drive: (on) => {
lamp = on
},
link,
})
// Each tick reads, decides, switches the lamp, and publishes the reading. The lamp comes on
// at 31.5 C or below and goes off at 32.5 C or above, and in between it stays as it was; a
// reading more than 4 C from 32 raises an alert as well.
let was = false
for (let at = 0; at < 5; at += 1) {
const { reading, reaction } = await node.tick()
const on = reaction.actuator === true
const change = on ? (was ? 'lamp stays on' : 'lamp on') : was ? 'lamp off' : 'lamp stays off'
const alert = reaction.alert ? `, alert ${reaction.alert.kind}` : ''
console.log(`${`${reading} C`.padEnd(10)}${change}${alert}`)
was = on
}
// The dashboard heard every reading the node published.
const heard: number[] = []
for (let at = 0; at < 5; at += 1) {
heard.push((await dashboard.recv())!.number!)
}
console.log(`heard ${heard.join(', ')} on ${profile.topic}`)
// Between ticks the node waits as long as its battery allows: often on a healthy charge,
// sparingly on a low one. run() does this until stopped, waiting each interval.
for (const charge of [0.8, 0.3, 0.1]) {
const { mode, waitMs } = node.schedule(charge)
console.log(`battery at ${(charge * 100).toFixed(0)}% it runs ${mode} and waits ${waitMs / 1000} s`)
}
// The same file says how a dashboard draws the node.
const element = profile.presentation!.elements[0]
const [low, high] = element.band!
console.log(`draws ${element.key} in ${element.unit} on a ${element.viz}, safe from ${low} to ${high}`)
return lamp
}The other policies, continuing from above:
From bindings/node/guides/profile.ts:
import { AlertKind } from '@pamoja/profile'
function kinds(): void {
// A level warns before a tank or a well runs dry. The shipped well profile counts 0.5 m
// as dry and warns once the last fall puts dry six samples away or nearer.
const well = Profile.wellLevel().controller()
for (const depth of [5.0, 4.4, 3.8]) {
const alert = well.evaluate(depth).alert
if (alert?.kind === AlertKind.RunningOut) {
console.log(`well ${depth} m: dry in ${alert.samples} samples at this rate, RunningOut`)
} else {
console.log(`well ${depth} m: no warning yet`)
}
}
// A surge warns when a reading moves too far in one sample. The shipped flood sensor warns
// when a river rises more than 0.3 m between two readings.
const river = Profile.floodSensor().controller()
for (const gauge of [1.2, 1.35, 1.9]) {
const alert = river.evaluate(gauge).alert
if (alert?.kind === AlertKind.ChangingFast) {
console.log(`river ${gauge} m: up ${alert.rate?.toFixed(2)} m in one sample, ChangingFast`)
} else {
console.log(`river ${gauge} m: no warning`)
}
}
}A control kind of the program's own, continuing from above:
From bindings/node/guides/profile.ts:
import { type Policy, PolicyRegistry } from '@pamoja/profile'
// A policy of the program's own: the lamp on below the setpoint, and a condition of its own
// when the chicks are chilled.
function brooderGuard(params: Record<string, number | boolean | string>): Policy {
const setpoint = Number(params.setpoint ?? 32)
const chilledBelow = setpoint - Number(params.safe_band ?? 4)
return {
evaluate: (reading) => ({
actuator: reading < setpoint,
alert: reading < chilledBelow ? { kind: AlertKind.Custom, code: 'Chilled', value: reading } : undefined,
}),
}
}
async function custom(): Promise<void> {
// A manifest may name a control kind the library never shipped, with its parameters
// beside it. The program registers the code that decides it under that name, and the node
// runs whichever kind the file names.
const guarded = Profile.fromJson(text.replace('"kind": "setpoint"', '"kind": "brooder_guard"'))
const registry = new PolicyRegistry().register('brooder_guard', brooderGuard)
console.log(`custom ${guarded.control.customKind} is decided by the program's own code, registered under its name`)
const link = new LoopbackBroker().link()
await link.connect()
let lamp = false
const node = new Node({
profile: guarded,
policy: registry,
read: () => 27.5,
drive: (on) => {
lamp = on
},
link,
})
const { reading, reaction } = await node.tick()
const alert = reaction.alert?.code ?? reaction.alert?.kind ?? 'none'
console.log(`${`${reading} C`.padEnd(10)}lamp ${lamp ? 'on' : 'off'}, alert ${alert}`)
}A profile that arrives over a link, continuing from above:
From bindings/node/guides/profile.ts:
import { DeviceIdentity, verifyMessage } from '@pamoja/security'
async function network(): Promise<Profile> {
// A profile can arrive over a link as well as from a disk: on an MQTT topic the gateway
// publishes to, or as the body of an HTTP response. The gateway signs what it sends and each
// node holds only the gateway's public key, so a profile is checked before it is read, and
// one from anywhere else never runs.
const gateway = DeviceIdentity.fromSeed(Buffer.alloc(32, 7))
const trusted = gateway.publicKey()
const broker = new LoopbackBroker()
const uplink = broker.link()
const downlink = broker.link()
await uplink.connect()
await downlink.connect()
const fleet = 'fleet/brooders/profile'
await downlink.subscribe(fleet)
await uplink.send(fleet, gateway.signMessage(text))
const signed = verifyMessage(trusted, (await downlink.recv())!.payload)
const delivered = Profile.fromJson(signed!.toString('utf8'))
console.log(`network ${delivered.name} arrived on ${fleet}, signed by the gateway, and loads`)
const stranger = DeviceIdentity.fromSeed(Buffer.alloc(32, 9))
await uplink.send(fleet, stranger.signMessage(text))
if (verifyMessage(trusted, (await downlink.recv())!.payload) === null) {
console.log('network one signed by any other key is refused before it is read')
}
return delivered
}What goes wrong, continuing from above:
From bindings/node/guides/profile.ts:
function wrong(): void {
// A probe that fails reports a reading that is not a number. The controller raises it
// rather than going quiet, and the lamp holds its state; what off means for the chicks is
// the node's call.
const controller = profile.controller()
controller.evaluate(27.5)
const failed = controller.evaluate(Number.NaN)
if (failed.alert) {
const holds = failed.actuator === true ? 'on' : 'off'
console.log(`probe a reading of NaN raises ${failed.alert.kind}, and the lamp holds ${holds}`)
}
// A controller built again for each reading forgets the lamp was on, so inside the
// deadband it switches the lamp off.
const first = profile.controller().evaluate(27.5).actuator
const then = profile.controller().evaluate(31.8).actuator
if (first === true && then === false) {
console.log('fresh built again for each reading, the controller turns the lamp off at 31.8 C')
}
// A manifest no node could run is refused as it loads, with the reason. So is a misspelled
// field, with the one it was probably meant to be and where it sits, rather than leaving
// the default in its place without a word.
for (const edited of [
text.replace('"hysteresis": 0.5', '"hysteresis": 0.0'),
text.replace('"saver_secs": 600', '"saver_secs": 60'),
text.replace('"saver_below"', '"saver_bellow"'),
]) {
try {
Profile.fromJson(edited)
console.log('a manifest no node could run was accepted, which should never happen')
} catch (error) {
console.log(`refused ${(error as Error).message}`)
}
}
// A kind the library does not ship loads with its parameters, but no built-in controller
// decides it, so a node without a registry that knows it is refused rather than running
// one that never switches the lamp.
const unknown = Profile.fromJson(text.replace('"kind": "setpoint"', '"kind": "brooder_guard"'))
try {
unknown.controller()
console.log('a custom kind ran without its policy, which should never happen')
} catch (error) {
console.log(`refused ${(error as Error).message}`)
}
}Python#
In Python, pamoja.profile loads a manifest with Profile.from_json, which raises
PamojaError for one no node could run. A Node takes the profile, a read
callable, a drive callable, and any link with send, each plain or async; tick
returns the reading and a reaction whose actuator is True, False, or None,
and run repeats it at the battery's cadence until its task is cancelled. A
PolicyRegistry maps a custom kind to a class or function that builds the program's
own policy, which returns a Decision.
From bindings/python/guides/profile.py:
from pathlib import Path
from pamoja.loopback import LoopbackBroker
from pamoja.profile import Node, Profile
# A profile is a file. This one ships in the catalog under profiles/: it holds a brooder at
# 32 C by switching a heat lamp, says what it reads, and says how a dashboard draws it.
text = Path("profiles/brooder-heater.json").read_text(encoding="utf-8")
profile = Profile.from_json(text)
print(
f"profile {profile.name} reads {profile.reads.quantity} in {profile.reads.unit} "
f"and reports on {profile.topic}"
)
async def example() -> bool:
# A node is the profile and the parts that make it run: a sensor, an output, and a link.
# A morning of readings stands in for the probe, and a dashboard listens on the same
# broker.
broker = LoopbackBroker()
link = broker.link()
dashboard = broker.link()
await link.connect()
await dashboard.connect()
await dashboard.subscribe(profile.topic)
morning = [27.5, 31.8, 32.6, 32.1, 31.4]
lamp = []
node = Node(profile, read=lambda: morning.pop(0), drive=lamp.append, link=link)
# Each tick reads, decides, switches the lamp, and publishes the reading. The lamp comes
# on at 31.5 C or below and goes off at 32.5 C or above, and in between it stays as it
# was; a reading more than 4 C from 32 raises an alert as well.
was = False
for _ in range(5):
tick = await node.tick()
on = tick.reaction.actuator is True
if on:
change = "lamp stays on" if was else "lamp on"
else:
change = "lamp off" if was else "lamp stays off"
alert = f", alert {tick.reaction.alert.kind}" if tick.reaction.alert else ""
print(f"{f'{tick.reading:g} C':<10}{change}{alert}")
was = on
# The dashboard heard every reading the node published.
heard = [f"{(await dashboard.recv()).number:g}" for _ in range(5)]
print(f"heard {', '.join(heard)} on {profile.topic}")
# Between ticks the node waits as long as its battery allows: often on a healthy charge,
# sparingly on a low one. run() does this until stopped, waiting each interval.
for charge in [0.8, 0.3, 0.1]:
mode, seconds = node.schedule(charge)
print(f"battery at {charge * 100:.0f}% it runs {mode} and waits {seconds:g} s")
# The same file says how a dashboard draws the node.
element = profile.presentation.elements[0]
low, high = element.band
print(
f"draws {element.key} in {element.unit} on a {element.viz}, "
f"safe from {low:g} to {high:g}"
)
return lamp[-1]
lamp_on = asyncio.run(example())The other policies, continuing from above:
From bindings/python/guides/profile.py:
from pamoja.profile import AlertKind
# A level warns before a tank or a well runs dry. The shipped well profile counts 0.5 m as
# dry and warns once the last fall puts dry six samples away or nearer.
well = Profile.well_level().controller()
for depth in [5.0, 4.4, 3.8]:
alert = well.evaluate(depth).alert
if alert and alert.kind == AlertKind.RUNNING_OUT:
print(f"well {depth:g} m: dry in {alert.samples} samples at this rate, RunningOut")
else:
print(f"well {depth:g} m: no warning yet")
# A surge warns when a reading moves too far in one sample. The shipped flood sensor warns
# when a river rises more than 0.3 m between two readings.
river = Profile.flood_sensor().controller()
for gauge in [1.2, 1.35, 1.9]:
alert = river.evaluate(gauge).alert
if alert and alert.kind == AlertKind.CHANGING_FAST:
print(f"river {gauge:g} m: up {alert.rate:.2f} m in one sample, ChangingFast")
else:
print(f"river {gauge:g} m: no warning")A control kind of the program's own, continuing from above:
From bindings/python/guides/profile.py:
from pamoja.profile import CustomAlert, Decision, PolicyRegistry
class BrooderGuard:
"""A policy of the program's own: the lamp on below the setpoint, and a condition of its
own when the chicks are chilled."""
def __init__(self, params: dict) -> None:
self.setpoint = params.get("setpoint", 32.0)
self.chilled_below = self.setpoint - params.get("safe_band", 4.0)
def evaluate(self, reading: float) -> Decision:
chilled = reading < self.chilled_below
alert = CustomAlert("Chilled", reading) if chilled else None
return Decision(actuator=reading < self.setpoint, alert=alert)
async def custom() -> None:
# A manifest may name a control kind the library never shipped, with its parameters
# beside it. The program registers the code that decides it under that name, and the
# node runs whichever kind the file names.
guarded = Profile.from_json(text.replace('"kind": "setpoint"', '"kind": "brooder_guard"'))
registry = PolicyRegistry().register("brooder_guard", BrooderGuard)
print(
f"custom {guarded.control.custom_kind} is decided by the program's own code, "
"registered under its name"
)
link = LoopbackBroker().link()
await link.connect()
lamp = []
node = Node(guarded, read=lambda: 27.5, drive=lamp.append, link=link, policy=registry)
tick = await node.tick()
alert = tick.reaction.alert.code if tick.reaction.alert else "none"
print(f"{f'{tick.reading:g} C':<10}lamp {'on' if lamp[-1] else 'off'}, alert {alert}")
asyncio.run(custom())A profile that arrives over a link, continuing from above:
From bindings/python/guides/profile.py:
from pamoja.security import DeviceIdentity, verify_message
async def network() -> Profile:
# A profile can arrive over a link as well as from a disk: on an MQTT topic the gateway
# publishes to, or as the body of an HTTP response. The gateway signs what it sends and
# each node holds only the gateway's public key, so a profile is checked before it is
# read, and one from anywhere else never runs.
gateway = DeviceIdentity.from_seed(bytes([7]) * 32)
trusted = gateway.public_key
broker = LoopbackBroker()
uplink = broker.link()
downlink = broker.link()
await uplink.connect()
await downlink.connect()
fleet = "fleet/brooders/profile"
await downlink.subscribe(fleet)
await uplink.send(fleet, gateway.sign_message(text.encode("utf-8")))
signed = verify_message(trusted, (await downlink.recv()).payload)
delivered = Profile.from_json(signed.decode("utf-8"))
print(f"network {delivered.name} arrived on {fleet}, signed by the gateway, and loads")
stranger = DeviceIdentity.from_seed(bytes([9]) * 32)
await uplink.send(fleet, stranger.sign_message(text.encode("utf-8")))
if verify_message(trusted, (await downlink.recv()).payload) is None:
print("network one signed by any other key is refused before it is read")
return delivered
delivered = asyncio.run(network())What goes wrong, continuing from above:
From bindings/python/guides/profile.py:
from pamoja.core import PamojaError
# A probe that fails reports a reading that is not a number. The controller raises it
# rather than going quiet, and the lamp holds its state; what off means for the chicks is
# the node's call.
controller = profile.controller()
controller.evaluate(27.5)
failed = controller.evaluate(float("nan"))
if failed.alert:
holds = "on" if failed.actuator is True else "off"
print(f"probe a reading of NaN raises {failed.alert.kind}, and the lamp holds {holds}")
# A controller built again for each reading forgets the lamp was on, so inside the deadband
# it switches the lamp off.
first = profile.controller().evaluate(27.5).actuator
then = profile.controller().evaluate(31.8).actuator
if first is True and then is False:
print("fresh built again for each reading, the controller turns the lamp off at 31.8 C")
# A manifest no node could run is refused as it loads, with the reason. So is a misspelled
# field, with the one it was probably meant to be and where it sits, rather than leaving the
# default in its place without a word.
for edited in [
text.replace('"hysteresis": 0.5', '"hysteresis": 0.0'),
text.replace('"saver_secs": 600', '"saver_secs": 60'),
text.replace('"saver_below"', '"saver_bellow"'),
]:
try:
Profile.from_json(edited)
print("a manifest no node could run was accepted, which should never happen")
except PamojaError as error:
print(f"refused {error}")
# A kind the library does not ship loads with its parameters, but no built-in controller
# decides it, so a node without a registry that knows it is refused rather than running one
# that never switches the lamp.
unknown = Profile.from_json(text.replace('"kind": "setpoint"', '"kind": "brooder_guard"'))
try:
unknown.controller()
print("a custom kind ran without its policy, which should never happen")
except PamojaError as error:
print(f"refused {error}")C##
In C#, a Profile, a Controller, and a Node hold native state and are disposed
with using. Profile.FromJson throws PamojaException for a manifest no node could
run. A Node takes the profile, a read function, a drive function, and any
ILink; TickAsync returns a Tick with the reading and a Reaction record whose
Actuator is a bool? and whose Alert carries an AlertKind, and RunAsync
repeats it at the battery's cadence until its token is cancelled. A
PolicyRegistry maps a custom kind to a factory for the program's own IPolicy.
From bindings/dotnet/samples/Pamoja.Guides/ProfileGuide.cs:
// A profile is a file. This one ships in the catalog under profiles/: it holds a
// brooder at 32 C by switching a heat lamp, says what it reads, and says how a
// dashboard draws it.
string text = File.ReadAllText("profiles/brooder-heater.json");
using var profile = Profile.FromJson(text);
Reads reads = profile.Reads!.Value;
Console.WriteLine($"profile {profile.Name} reads {reads.Quantity} in {reads.Unit} and reports on {profile.Topic}");
// A node is the profile and the parts that make it run: a sensor, an output, and a
// link. A morning of readings stands in for the probe, and a dashboard listens on the
// same broker.
using var broker = new LoopbackBroker();
using var link = broker.Link();
using var dashboard = broker.Link();
await link.ConnectAsync();
await dashboard.ConnectAsync();
await dashboard.SubscribeAsync(profile.Topic);
var morning = new Queue<float>([27.5f, 31.8f, 32.6f, 32.1f, 31.4f]);
bool lamp = false;
using var node = new Node(
profile,
() => ValueTask.FromResult(morning.Dequeue()),
link,
drive: on =>
{
lamp = on;
return ValueTask.CompletedTask;
});
// Each tick reads, decides, switches the lamp, and publishes the reading. The lamp
// comes on at 31.5 C or below and goes off at 32.5 C or above, and in between it stays
// as it was; a reading more than 4 C from 32 raises an alert as well.
bool was = false;
for (int at = 0; at < 5; at++)
{
Tick tick = await node.TickAsync();
bool on = tick.Reaction.Actuator == true;
string change = on ? (was ? "lamp stays on" : "lamp on") : was ? "lamp off" : "lamp stays off";
string alert = tick.Reaction.Alert is { } raised ? $", alert {raised.Kind}" : string.Empty;
Console.WriteLine(Invariant($"{Invariant($"{tick.Reading} C"),-10}{change}{alert}"));
was = on;
}
// The dashboard heard every reading the node published.
var heard = new List<string>();
for (int at = 0; at < 5; at++)
{
TransportMessage? message = await dashboard.ReceiveAsync(TimeSpan.FromSeconds(5));
heard.Add(Invariant($"{message!.Number}"));
}
Console.WriteLine($"heard {string.Join(", ", heard)} on {profile.Topic}");
// Between ticks the node waits as long as its battery allows: often on a healthy
// charge, sparingly on a low one. RunAsync does this until cancelled, waiting each
// interval.
foreach (float charge in new[] { 0.8f, 0.3f, 0.1f })
{
(var mode, TimeSpan wait) = node.Schedule(charge);
Console.WriteLine(Invariant($"battery at {charge * 100:F0}% it runs {mode} and waits {wait.TotalSeconds} s"));
}
// The same file says how a dashboard draws the node.
ElementSpec element = profile.Presentation!.Elements[0];
string graphic = element.Viz.ToString().ToLowerInvariant();
Console.WriteLine(Invariant(
$"draws {element.Key} in {element.Unit} on a {graphic}, safe from {element.Band![0]} to {element.Band[1]}"));The other policies, continuing from above:
From bindings/dotnet/samples/Pamoja.Guides/ProfileGuide.cs:
// A level warns before a tank or a well runs dry. The shipped well profile counts
// 0.5 m as dry and warns once the last fall puts dry six samples away or nearer.
using (var wellLevel = Profile.WellLevel())
using (Controller well = wellLevel.Controller())
{
foreach (float depth in new[] { 5.0f, 4.4f, 3.8f })
{
Console.WriteLine(well.Evaluate(depth).Alert is { Kind: AlertKind.RunningOut } alert
? Invariant($"well {depth} m: dry in {alert.Samples} samples at this rate, RunningOut")
: Invariant($"well {depth} m: no warning yet"));
}
}
// A surge warns when a reading moves too far in one sample. The shipped flood sensor
// warns when a river rises more than 0.3 m between two readings.
using var floodSensor = Profile.FloodSensor();
using (Controller river = floodSensor.Controller())
{
foreach (float gauge in new[] { 1.2f, 1.35f, 1.9f })
{
Console.WriteLine(river.Evaluate(gauge).Alert is { Kind: AlertKind.ChangingFast } alert
? Invariant($"river {gauge} m: up {alert.Rate:F2} m in one sample, ChangingFast")
: Invariant($"river {gauge} m: no warning"));
}
}A control kind of the program's own, continuing from above:
From bindings/dotnet/samples/Pamoja.Guides/ProfileGuide.cs:
/// <summary>
/// A policy of the program's own: the lamp on below the setpoint, and a condition of its
/// own when the chicks are chilled.
/// </summary>
private sealed class BrooderGuard(IReadOnlyDictionary<string, object> parameters) : IPolicy
{
private readonly float _setpoint = Convert.ToSingle(parameters["setpoint"]);
private readonly float _chilledBelow = Convert.ToSingle(parameters["setpoint"]) - Convert.ToSingle(parameters["safe_band"]);
public Reaction Evaluate(float reading) => new(
reading < _setpoint,
reading < _chilledBelow ? new Alert(AlertKind.Custom, null, null, null, "Chilled", reading) : null);
}
private static async Task CustomAsync(string text)
{
// A manifest may name a control kind the library never shipped, with its parameters
// beside it. The program registers the code that decides it under that name, and the
// node runs whichever kind the file names.
using var guarded = Profile.FromJson(text.Replace("\"kind\": \"setpoint\"", "\"kind\": \"brooder_guard\""));
var registry = new PolicyRegistry().Register("brooder_guard", parameters => new BrooderGuard(parameters));
Console.WriteLine($"custom {guarded.Control.CustomKind} is decided by the program's own code, registered under its name");
using var broker = new LoopbackBroker();
using var link = broker.Link();
await link.ConnectAsync();
bool lamp = false;
using var node = new Node(
guarded,
() => ValueTask.FromResult(27.5f),
link,
drive: on =>
{
lamp = on;
return ValueTask.CompletedTask;
},
policy: registry.Resolve(guarded));
Tick tick = await node.TickAsync();
string alert = tick.Reaction.Alert?.Code ?? tick.Reaction.Alert?.Kind.ToString() ?? "none";
Console.WriteLine(Invariant($"{Invariant($"{tick.Reading} C"),-10}lamp {(lamp ? "on" : "off")}, alert {alert}"));
}A profile that arrives over a link, continuing from above:
From bindings/dotnet/samples/Pamoja.Guides/ProfileGuide.cs:
private static async Task<Profile> NetworkAsync(string text)
{
// A profile can arrive over a link as well as from a disk: on an MQTT topic the
// gateway publishes to, or as the body of an HTTP response. The gateway signs what it
// sends and each node holds only the gateway's public key, so a profile is checked
// before it is read, and one from anywhere else never runs.
byte[] seed = new byte[DeviceIdentity.KeyLength];
Array.Fill(seed, (byte)7);
using var gateway = new DeviceIdentity(seed);
byte[] trusted = gateway.PublicKey;
using var broker = new LoopbackBroker();
using var uplink = broker.Link();
using var downlink = broker.Link();
await uplink.ConnectAsync();
await downlink.ConnectAsync();
const string fleet = "fleet/brooders/profile";
await downlink.SubscribeAsync(fleet);
byte[] manifest = Encoding.UTF8.GetBytes(text);
await uplink.SendAsync(fleet, gateway.SignMessage(manifest));
TransportMessage? message = await downlink.ReceiveAsync(TimeSpan.FromSeconds(5));
byte[]? signed = DeviceIdentity.VerifyMessage(trusted, message!.Payload);
var delivered = Profile.FromJson(Encoding.UTF8.GetString(signed!));
Console.WriteLine($"network {delivered.Name} arrived on {fleet}, signed by the gateway, and loads");
Array.Fill(seed, (byte)9);
using var stranger = new DeviceIdentity(seed);
await uplink.SendAsync(fleet, stranger.SignMessage(manifest));
message = await downlink.ReceiveAsync(TimeSpan.FromSeconds(5));
if (DeviceIdentity.VerifyMessage(trusted, message!.Payload) is null)
{
Console.WriteLine("network one signed by any other key is refused before it is read");
}
return delivered;
}What goes wrong, continuing from above:
From bindings/dotnet/samples/Pamoja.Guides/ProfileGuide.cs:
// A probe that fails reports a reading that is not a number. The controller raises
// it rather than going quiet, and the lamp holds its state; what off means for the
// chicks is the node's call.
using (Controller controller = profile.Controller())
{
controller.Evaluate(27.5f);
Reaction failed = controller.Evaluate(float.NaN);
if (failed.Alert is { } invalid)
{
string holds = failed.Actuator == true ? "on" : "off";
Console.WriteLine($"probe a reading of NaN raises {invalid.Kind}, and the lamp holds {holds}");
}
}
// A controller built again for each reading forgets the lamp was on, so inside the
// deadband it switches the lamp off.
bool? first;
bool? then;
using (Controller once = profile.Controller())
{
first = once.Evaluate(27.5f).Actuator;
}
using (Controller again = profile.Controller())
{
then = again.Evaluate(31.8f).Actuator;
}
if (first == true && then == false)
{
Console.WriteLine("fresh built again for each reading, the controller turns the lamp off at 31.8 C");
}
// A manifest no node could run is refused as it loads, with the reason. So is a
// misspelled field, with the one it was probably meant to be and where it sits,
// rather than leaving the default in its place without a word.
foreach (string edited in new[]
{
text.Replace("\"hysteresis\": 0.5", "\"hysteresis\": 0.0"),
text.Replace("\"saver_secs\": 600", "\"saver_secs\": 60"),
text.Replace("\"saver_below\"", "\"saver_bellow\""),
})
{
try
{
using var accepted = Profile.FromJson(edited);
Console.WriteLine("a manifest no node could run was accepted, which should never happen");
}
catch (PamojaException error)
{
Console.WriteLine($"refused {error.Message}");
}
}
// A kind the library does not ship loads with its parameters, but no built-in
// controller decides it, so a node without a registry that knows it is refused
// rather than running one that never switches the lamp.
using var unknown = Profile.FromJson(text.Replace("\"kind\": \"setpoint\"", "\"kind\": \"brooder_guard\""));
try
{
using Controller inert = unknown.Controller();
Console.WriteLine("a custom kind ran without its policy, which should never happen");
}
catch (PamojaException error)
{
Console.WriteLine($"refused {error.Message}");
}Values at a glance#
Every field a manifest may carry, with what it holds and its default, is under Every field below, generated from the published schema an editor checks the file with.
The control kinds:
kind | Fields | What it does with each reading | Alert |
|---|---|---|---|
setpoint | setpoint, hysteresis, cooling, safe_band | a heater switches on at setpoint - hysteresis or below and off at setpoint + hysteresis or above, a cooler the other way round, and in between the output stays as it was | OutOfRange when the reading is more than safe_band from the setpoint |
level | empty, warn_within | drives nothing; estimates how many more samples the last fall takes to reach empty, rounded up | RunningOut once that is warn_within or fewer |
surge | rising, limit | drives nothing; compares each reading with the one before | ChangingFast when one sample moves more than limit the watched way |
monitor | none | drives nothing and raises nothing | none |
| any other | its own, kept as parameters | whatever the code registered for the kind decides: through a PolicyRegistry in Rust, from control. elsewhere; asking for a built-in controller is refused | whatever that policy raises |
A level and a surge need a reading before they can compare, so the first one never warns.
The alerts:
| Kind | Raised when | Carries |
|---|---|---|
OutOfRange | a setpoint reading is further from the target than the safe band | the reading |
RunningOut | a falling level is on course to reach empty within the warning | the samples left |
ChangingFast | one sample moved further than the surge limit | the change |
InvalidReading | a reading is not a finite number, under every kind but a monitor | the reading as it arrived |
Custom | a policy of your own raised it | the code it chose, and a value |
The power schedule. A charge below critical_below is critical, below
saver_below is saver, and anything else is active. A charge that is not a number,
from a fuel gauge that failed to answer, is taken as critical. While the panel charges, the plan eases
the node up one step, from critical to saver and from saver to active. A running
node remembers its mode: it drops a cadence as soon as the charge crosses a
threshold, and climbs back only once the charge is hysteresis above it, so a
charge hovering at 50% does not switch the cadence on every sample.
What a manifest is refused for, as it loads and as each language's constructor builds one:
| The manifest | The reason says |
|---|---|
a field the format does not have, such as a misspelled saver_bellow | unknown field `saver_bellow`, and the field it was probably meant to be |
a $schema for another format | written in profile format 2, or that it is not a pamoja profile schema |
| an empty name | `name` must not be empty |
a reads quantity or unit that is not lowercase words joined by underscores | must be lowercase words joined by underscores |
| a custom kind with no name | a custom control needs a kind to be named by |
a topic that is empty, or holds + or # | the topic is empty, or that it is a filter |
| a control value that is not a finite number | must be a finite number |
| a hysteresis of zero or less | the output chatters at the setpoint |
| a safe band narrower than the hysteresis | an alert would fire inside the deadband |
| a level that warns within no samples | the warning never comes |
| a surge limit of zero or less | every sample is a surge |
an active_secs of zero | `active_secs` must be at least one second |
| intervals that shorten as the battery drains | the intervals must not shorten as the battery drains |
a saver_below of 0 or less or above 1, or a critical_below not between 0 and it | the threshold and its value |
a power hysteresis below 0, or one that puts saver_below plus it above 1 | the power `hysteresis` must be 0 or more and its value |
a dashboard element with a band whose low end is not first, a key given twice, or a state that is not a state. code | the element and what is wrong with it |
The catalog in Profiles holds its own files to more: a file
named for the profile, a description, a reads, and snake_case keys and kinds.
Asking a profile whose kind is custom for a built-in controller is refused too,
naming the kind: no policy decides the control kind `brooder_guard`, with
the built-in kind it is probably a misspelling of when there is one.
The calls in each language:
Rust#
| To | Call |
|---|---|
| load and write | Profile::, to_json(), check() |
| start from a preset | Profile::, irrigation_node(), well_level(), flood_sensor() |
| build one | Profile::, with_reads, with_thresholds, with_description, with_presentation, with_element |
| run it | Node::, tick() for a Tick of reading and reaction, schedule(, run( |
| decide a reading | controller(), then evaluate(; Controller::, level, surge, monitor |
| read the reaction | actuator, alert, Alert:: |
| schedule sampling | power., then mode(, mode_while_charging(, interval( |
| supply a policy | impl Policy, PolicyRegistry::, resolve(, Node::, Node:: |
TypeScript#
| To | Call |
|---|---|
| load and write | Profile., toJson() |
| start from a preset | Profile., irrigationNode(), wellLevel(), floodSensor() |
| build one | new Profile(, withReads, withDescription, withPresentation |
| run it | new Node(, tick() for reading and reaction, schedule(, run( |
| supply a policy | new PolicyRegistry()., resolve(, or the registry as the node's policy |
| decide a reading | controller(), then evaluate(; Controller., level, surge, monitor |
| read the reaction | actuator, alert., AlertKind |
| schedule sampling | powerPlan(), then mode(, modeWhileCharging(, intervalUs( |
| read what it reads | reads?., reads?. |
| read a custom kind | control., control., control. |
Python#
| To | Call |
|---|---|
| load and write | Profile., to_json() |
| start from a preset | Profile., irrigation_node(), well_level(), flood_sensor() |
| build one | Profile(, with_reads, with_description, with_presentation |
| run it | Node(, tick() for reading and reaction, schedule(, run( |
| supply a policy | PolicyRegistry()., resolve(, or the registry as the node's policy; a policy returns a Decision |
| decide a reading | controller(), then evaluate(; Controller., level, surge, monitor |
| read the reaction | actuator, alert., AlertKind |
| schedule sampling | power_plan(), then mode(, mode_while_charging(, interval_us( |
| read what it reads | reads., reads. |
| read a custom kind | control., control., control. |
C##
| To | Call |
|---|---|
| load and write | Profile., ToJson() |
| start from a preset | Profile., IrrigationNode(), WellLevel(), FloodSensor() |
| build one | new Profile(, WithReads, WithDescription, WithPresentation |
| run it | new Node(, TickAsync() for a Tick, Schedule(, RunAsync( |
| supply a policy | new PolicyRegistry()., Resolve( as the node's policy, over an IPolicy |
| decide a reading | Controller(), then Evaluate(; Controller., Level, Surge, Monitor |
| read the reaction | Actuator, Alert?., AlertKind |
| schedule sampling | PowerPlan, then Mode(, ModeWhileCharging(, IntervalUs( |
| read what it reads | Reads?., Reads?. |
| read a custom kind | Control., Control., Control. |
Every field#
These tables are generated from the published JSON Schema,
profile-1.json, the same file
an editor checks a manifest against. Name it as the manifest's $schema and an
editor such as VS Code completes the fields and marks a wrong one as it is typed.
The schema cannot say that a safe band is at least as wide as its hysteresis, that
the sampling intervals lengthen as the battery drains, or that an element's key is
used once, so the library's own check is the last word on those.
The profile file#
A node written down as data: what it reads, where it reports, how it decides each reading, how often it samples as its battery drains, and how a dashboard draws it.
| Field | Value | Required | What it does |
|---|---|---|---|
$schema | text | no | The format the file is written in: this schema's address, or a copy of it by the same file name. An editor reads it to check the file as it is typed. |
name | text | yes | A stable name, lowercase words joined by hyphens, such as brooder-heater. A shared profile's file carries the same name. |
description | text | no | What the profile is for, in a sentence or two: what it watches or holds, and what it does when a reading crosses a line. |
reads | object, see reads | no | What the profile reads. The setpoint, bands, and limits under control are in this unit. |
topic | text | yes | The topic each reading is published to, such as poultry/brooder/temperature. One topic, so no + or # wildcards. |
control | object, one of the control kinds below | yes | How each reading is decided. The kind names the policy and the other fields tune it. |
power | object, see power | yes | How often the node samples as its battery drains. The intervals may not shorten as the charge falls. |
presentation | object, see presentation | no | How a dashboard draws the node: the readings it adds, a tint, and the words for its own states. |
reads#
What the profile reads. The setpoint, bands, and limits under control are in this unit.
| Field | Value | Required | What it does |
|---|---|---|---|
quantity | text | yes | The quantity its control decides on, in lowercase words joined by underscores, such as temperature, relative_humidity, soil_moisture, water_level, or pressure. |
unit | text | yes | The unit its numbers are in, in lowercase words joined by underscores, such as celsius, percent, meter, or bar. |
control, kind setpoint#
Holds a reading near a target by switching an output on and off, and alerts when the reading strays too far.
| Field | Value | Required | What it does |
|---|---|---|---|
kind | setpoint | yes | Names this policy. |
setpoint | number | yes | The target reading, such as 32 for a brooder in celsius. |
hysteresis | number, above 0 | yes | Half the deadband around the setpoint. The output switches at the setpoint less and plus this, so it does not chatter. |
cooling | true or false | yes | true for an output that switches on above the band, such as a cooler; false for one that switches on below it, such as a heater or a valve that adds water. |
safe_band | number | yes | How far the reading may stray from the setpoint before an OutOfRange alert. No narrower than the hysteresis. |
control, kind level#
Watches a falling level and warns before it runs out.
| Field | Value | Required | What it does |
|---|---|---|---|
kind | level | yes | Names this policy. |
empty | number | yes | The level treated as empty, such as 0.5 for a well in meters. |
warn_within | whole number, at least 1 | yes | Raise RunningOut once the level is on course to reach empty within this many more samples. |
control, kind surge#
Warns when a reading changes faster than is safe, such as a river rising into a flash flood.
| Field | Value | Required | What it does |
|---|---|---|---|
kind | surge | yes | Names this policy. |
rising | true or false | yes | true to watch for a rapid rise, false for a rapid fall. |
limit | number, above 0 | yes | The largest safe change between two samples. A bigger one raises ChangingFast. |
control, kind monitor#
Reports readings and decides nothing: no output, no alerts.
| Field | Value | Required | What it does |
|---|---|---|---|
kind | monitor | yes | Names this policy. |
control, a kind of your own#
A policy the library does not ship. Its parameters sit beside the kind, and the program that runs the profile registers the code that decides it.
| Field | Value | Required | What it does |
|---|---|---|---|
kind | text | yes | The kind's own name, in lowercase words joined by underscores, such as frost_guard. |
| any other field | number, true or false, text | no | A parameter the kind's code reads: a number, true or false, or text. |
power#
How often the node samples as its battery drains. The intervals may not shorten as the charge falls.
| Field | Value | Required | What it does |
|---|---|---|---|
active_secs | whole number, at least 1 | yes | Seconds between samples on a healthy battery. |
saver_secs | whole number, at least 1 | yes | Seconds between samples below saver_below. No shorter than active_secs. |
critical_secs | whole number, at least 1 | yes | Seconds between samples below critical_below. No shorter than saver_secs. |
saver_below | number, above 0, at most 1 | no, 0. | The state of charge, from 0 to 1, below which the node samples at saver_secs. |
critical_below | number, above 0, at most 1 | no, 0. | The state of charge below which the node samples at critical_secs. Below saver_below. |
hysteresis | number, at least 0 | no, 0. | How far above a threshold the charge must climb to leave the slower cadence, so a charge hovering at a threshold does not switch it every cycle. |
presentation#
How a dashboard draws the node: the readings it adds, a tint, and the words for its own states.
| Field | Value | Required | What it does |
|---|---|---|---|
elements | list of objects, see presentation elements | no, [] | The readings and node stats the dashboard draws, in the order they are offered. |
theme | object, see presentation theme | no | A tint for the dashboard. Each color is any CSS color. |
messages | object of texts | no | The words for each state. or event. code the profile introduces, keyed by the code, such as state.lamp_on. |
presentation elements#
One reading or node stat on the dashboard.
| Field | Value | Required | What it does |
|---|---|---|---|
key | text | yes | The stable key the value travels under, in lowercase words joined by underscores, such as brooder_temperature. Used once per profile. |
unit | text | yes | The unit's name, such as celsius, percent, ntu, or state for an on and off reading. |
label | text | yes | The label shown when the reader's language has none of its own. |
labels | object of texts | no | The label in other languages, keyed by locale tag, such as fr or sw. |
viz | one of spark, gauge, dial, bar, thermometer, droplet, battery, wind, sun, wave, switch, valve, chain, mesh, count | yes | The graphic the value is drawn with. |
band | list of 2 numbers | no | The safe band, [low, high] in the element's unit, drawn as the graphic's safe zone. The low end comes first. |
stat | true or false | no, false | true for telemetry about the node itself, such as a battery or dropped packets, which the dashboard counts apart from the world it measures. |
scope | always, or object | no, always | Which groups offer the element: always, or { "links": [...] } for groups on those link kinds only, such as mesh. |
span | true or false | no, false | true to give a wide graphic two columns. |
value | number | no | The value shown until the first real sample arrives. |
state | text | no | The state shown until the first real sample arrives, for an on and off reading, such as state.lamp_off. Not together with value. |
presentation theme#
A tint for the dashboard. Each color is any CSS color.
| Field | Value | Required | What it does |
|---|---|---|---|
accent | text | no | Links, focus, and the brand mark. |
ok | text | no | A healthy reading, and an in-band gauge. |
warn | text | no | A warning. |
alarm | text | no | An alarm. |
track | text | no | The unfilled rail behind gauges and bars. |
When it goes wrong#
A manifest a node could not run is refused as it loads. What gets past that shows up as an output that does the wrong thing, or a warning that never comes. The ones that cost an afternoon:
- The output flickers, or a level never warns. A controller is built fresh by
every call to
controller, and it remembers whether its output is on and what the reading before was. Build one for each reading and a heater switches off inside its deadband, and a level or a surge never has a reading to compare against. Build one when the node starts and hand it every reading. - A failed probe leaves the heater on. A reading that is not a number raises
InvalidReading, and the output holds its last state rather than acting on a number that is not there. Decide in the node what off means for its plant, and turn the output off, or raise an alarm, when the alert comes. - A manifest from a shared folder is refused for a field. The format refuses a
field it does not have rather than ignore it, so a misspelled
saver_bellowcannot leave the default in place without a word. The reason names the field it was probably meant to be and where in the file it sits. Name the schema as the manifest's$schemaand an editor marks the same field as it is typed. - A custom kind is refused as the node starts. A kind the library does not ship
loads with its fields as parameters, but no built-in controller decides it, so
controllerrefuses it rather than hand back a node that reports readings and never drives its output. Register the code that decides it: through aPolicyRegistryin Rust, or in the program's own code fromcontrol.paramselsewhere. A kind a letter away from a built-in one is named in the reason. - A warning means something else on a low battery.
warn_withincounts samples, not minutes, so on the shipped well profile six samples are an hour ahead of dry at the active cadence and three hours at the saver one. A surge's limit is per sample too, so on the flood sensor a steady rise of 0.1 m a minute stays under the 0.3 m limit at the active 60 s and crosses it at the saver 300 s. Set both knowing the cadence stretches them, and check the numbers at each. - A glitch raises a flood warning. A surge compares two readings and nothing else, so one bad sample from a noisy gauge looks like a jump. Pass the readings through a median or a smoother first when the sensor is noisy.
- A manifest is refused. The reason names the field. A hysteresis has to be above zero or the output chatters, a safe band has to be at least as wide as the hysteresis, and the intervals have to lengthen as the battery drains.
Where next#
- Rules: Rules between nodes as a file.
- Your own device: A sensor and an actuator pamoja has never heard of, written against the core traits, run against a rule, and published with nothing plugged in.
- Sensor drivers: Datasheet-anchored drivers for eleven parts, from every language.
- Beside it: Profiles, Running a profile.
- Also in Profiles and robotics: Robot motion, ROS 2 rules, Zenoh keys.
Reference#
- Rust:
pamoja-profile, install - TypeScript:
@pamoja/profile, install - Python:
pamoja.profile, install - C#:
Pamoja.Profile, install