pamoja.profile

Idiomatic device-profile facade.

A profile is a named, pre-wired bundle: a control policy, a publish topic, and a power schedule. Instantiate one rather than choosing algorithms and tuning constants by hand.

A Profile is the manifest, which loads from and saves to JSON so it ships as a file. A Controller is the decision logic that manifest describes: hand it a reading and it says what the output should do and whether the reading crossed a threshold worth raising. The presentation a dashboard reads travels inside the manifest JSON and is read and built here as a typed Presentation of ElementSpec entries, drawn with a Viz.

A Node runs a profile: each tick reads, lets the profile's policy decide, switches the output, and publishes the reading over a link, and Node.run() repeats that at the cadence the battery allows. A PolicyRegistry resolves a control kind the library never shipped to the program's own code, which returns a Decision.

A rule file decides between nodes: a RuleEngine runs it off a link, switching outputs by name and publishing as rules set and clear, and a RuleEvaluator is its deciding half for a program that moves its own messages.

  1"""Idiomatic device-profile facade.
  2
  3A profile is a named, pre-wired bundle: a control policy, a publish topic, and a
  4power schedule. Instantiate one rather than choosing algorithms and tuning
  5constants by hand.
  6
  7A :class:`Profile` is the manifest, which loads from and saves to JSON so it
  8ships as a file. A :class:`Controller` is the decision logic that manifest
  9describes: hand it a reading and it says what the output should do and whether
 10the reading crossed a threshold worth raising. The presentation a dashboard
 11reads travels inside the manifest JSON and is read and built here as a typed
 12:class:`Presentation` of :class:`ElementSpec` entries, drawn with a :class:`Viz`.
 13
 14A :class:`Node` runs a profile: each tick reads, lets the profile's policy decide,
 15switches the output, and publishes the reading over a link, and :meth:`Node.run`
 16repeats that at the cadence the battery allows. A :class:`PolicyRegistry` resolves
 17a control kind the library never shipped to the program's own code, which returns
 18a :class:`Decision`.
 19
 20A rule file decides between nodes: a :class:`RuleEngine` runs it off a link,
 21switching outputs by name and publishing as rules set and clear, and a
 22:class:`RuleEvaluator` is its deciding half for a program that moves its own
 23messages.
 24"""
 25
 26from __future__ import annotations
 27
 28import enum
 29
 30from pamoja._native import (
 31    AlertReport,
 32    ControlPolicy,
 33    Controller,
 34    ElementSpec,
 35    PowerScheduleSpec,
 36    Presentation,
 37    Profile,
 38    Reaction,
 39    Reads,
 40    RuleAction,
 41    RuleEvaluator,
 42    RuleFired,
 43    Theme,
 44)
 45
 46from ._engine import ReceivingLink, RuleEngine
 47from ._node import CustomAlert, Decision, Link, Node, Policy, PolicyRegistry, Tick
 48
 49__all__ = [
 50    "AlertKind",
 51    "AlertReport",
 52    "ControlKind",
 53    "ControlPolicy",
 54    "Controller",
 55    "CustomAlert",
 56    "Decision",
 57    "ElementSpec",
 58    "Link",
 59    "Node",
 60    "Policy",
 61    "PolicyRegistry",
 62    "PowerScheduleSpec",
 63    "Presentation",
 64    "Profile",
 65    "Reaction",
 66    "Reads",
 67    "ReceivingLink",
 68    "RuleAction",
 69    "RuleActionKind",
 70    "RuleEngine",
 71    "RuleEvaluator",
 72    "RuleFired",
 73    "Theme",
 74    "Tick",
 75    "Viz",
 76]
 77
 78
 79class RuleActionKind(str, enum.Enum):
 80    """What a rule calls for when its condition sets or clears."""
 81
 82    #: Switch the output the program holds under ``actuator``.
 83    DRIVE = "drive"
 84    #: Publish ``payload`` to ``topic``.
 85    PUBLISH = "publish"
 86
 87
 88class Viz(str, enum.Enum):
 89    """The graphic a dashboard draws an element with, named by the instrument rather
 90    than the quantity. The values are the ones a manifest carries."""
 91
 92    #: A rolling sparkline of recent values.
 93    SPARK = "spark"
 94    #: A 270-degree arch gauge, for a fraction or percentage.
 95    GAUGE = "gauge"
 96    #: A half-dial with a needle, for a pressure or flow reading.
 97    DIAL = "dial"
 98    #: A horizontal bar with a safe-band tick, for a level or stock.
 99    BAR = "bar"
100    #: A thermometer, for a temperature.
101    THERMOMETER = "thermometer"
102    #: A liquid-filled droplet, for humidity or moisture.
103    DROPLET = "droplet"
104    #: A segmented battery cell, for a state of charge or voltage.
105    BATTERY = "battery"
106    #: An anemometer, for wind speed.
107    WIND = "wind"
108    #: A sun whose corona grows with the reading, for illuminance.
109    SUN = "sun"
110    #: An acoustic waveform, for sound level or an acoustic event.
111    WAVE = "wave"
112    #: A labeled state chip, lit when the state reads as on.
113    SWITCH = "switch"
114    #: A pipe valve, open along the flow or closed across it.
115    VALVE = "valve"
116    #: A row of hash-chained blocks, for a tamper-evident record count.
117    CHAIN = "chain"
118    #: A neighbor-mesh topology map, for a mesh node's peers.
119    MESH = "mesh"
120    #: A plain numeric counter, for a node or network stat.
121    COUNT = "count"
122
123
124class ControlKind(str, enum.Enum):
125    """Which control policy a profile applies to each reading."""
126
127    #: Hold a reading near a setpoint by switching an output on and off.
128    SETPOINT = "Setpoint"
129    #: Watch a falling level and warn before it reaches empty.
130    LEVEL = "Level"
131    #: Warn when a reading changes faster than a limit.
132    SURGE = "Surge"
133    #: Report readings only, with no output and no alerts.
134    MONITOR = "Monitor"
135    #: A kind the library does not ship, named by the manifest in ``custom_kind`` and
136    #: decided by the program's own code from ``params``.
137    CUSTOM = "Custom"
138
139
140class AlertKind(str, enum.Enum):
141    """Which threshold a reading crossed."""
142
143    #: A controlled reading drifted outside its safe band.
144    OUT_OF_RANGE = "OutOfRange"
145    #: A falling level will reach empty within a few more samples.
146    RUNNING_OUT = "RunningOut"
147    #: A reading is changing faster than its safe rate.
148    CHANGING_FAST = "ChangingFast"
149    #: A reading that is not a finite number, such as the NaN a failed probe produces.
150    #: It changes nothing: a setpoint's output holds, and a level or a surge carries on
151    #: from the last good reading.
152    INVALID_READING = "InvalidReading"
153    #: A condition a policy of the program's own raised, named by ``code``.
154    CUSTOM = "Custom"
class AlertKind(builtins.str, enum.Enum):
141class AlertKind(str, enum.Enum):
142    """Which threshold a reading crossed."""
143
144    #: A controlled reading drifted outside its safe band.
145    OUT_OF_RANGE = "OutOfRange"
146    #: A falling level will reach empty within a few more samples.
147    RUNNING_OUT = "RunningOut"
148    #: A reading is changing faster than its safe rate.
149    CHANGING_FAST = "ChangingFast"
150    #: A reading that is not a finite number, such as the NaN a failed probe produces.
151    #: It changes nothing: a setpoint's output holds, and a level or a surge carries on
152    #: from the last good reading.
153    INVALID_READING = "InvalidReading"
154    #: A condition a policy of the program's own raised, named by ``code``.
155    CUSTOM = "Custom"

Which threshold a reading crossed.

OUT_OF_RANGE = <AlertKind.OUT_OF_RANGE: 'OutOfRange'>
RUNNING_OUT = <AlertKind.RUNNING_OUT: 'RunningOut'>
CHANGING_FAST = <AlertKind.CHANGING_FAST: 'ChangingFast'>
INVALID_READING = <AlertKind.INVALID_READING: 'InvalidReading'>
CUSTOM = <AlertKind.CUSTOM: 'Custom'>
class AlertReport:

An alert a reading raised.

Only the attribute belonging to kind is set; the rest are None.

kind

Which threshold the reading crossed: OutOfRange, RunningOut, ChangingFast, InvalidReading for a reading that is not a finite number, or Custom for a condition the program's own policy raised.

samples

The estimated samples until empty, for a running-out alert.

rate

The change since the previous sample, for a changing-fast alert.

reading

The offending reading, for an out-of-range or invalid-reading alert.

code

The condition's name, for a custom alert.

value

The measurement behind the condition, for a custom alert.

class ControlKind(builtins.str, enum.Enum):
125class ControlKind(str, enum.Enum):
126    """Which control policy a profile applies to each reading."""
127
128    #: Hold a reading near a setpoint by switching an output on and off.
129    SETPOINT = "Setpoint"
130    #: Watch a falling level and warn before it reaches empty.
131    LEVEL = "Level"
132    #: Warn when a reading changes faster than a limit.
133    SURGE = "Surge"
134    #: Report readings only, with no output and no alerts.
135    MONITOR = "Monitor"
136    #: A kind the library does not ship, named by the manifest in ``custom_kind`` and
137    #: decided by the program's own code from ``params``.
138    CUSTOM = "Custom"

Which control policy a profile applies to each reading.

SETPOINT = <ControlKind.SETPOINT: 'Setpoint'>
LEVEL = <ControlKind.LEVEL: 'Level'>
SURGE = <ControlKind.SURGE: 'Surge'>
MONITOR = <ControlKind.MONITOR: 'Monitor'>
CUSTOM = <ControlKind.CUSTOM: 'Custom'>
class ControlPolicy:

A profile's control policy.

Only the attributes belonging to kind are set; the rest are None. A Custom kind is one the library does not ship: the manifest names it in custom_kind, and every field beside the kind is in params, for the program's own code to decide.

hysteresis

Half the deadband width, for a setpoint policy.

empty

The level treated as empty, for a level policy.

limit

The largest safe change per sample, for a surge policy.

setpoint

The target reading, for a setpoint policy.

cooling

Whether the output cools rather than heats, for a setpoint policy.

safe_band

How far the reading may stray before an alert, for a setpoint policy.

kind

Which policy this describes: Setpoint, Level, Surge, Monitor, or Custom.

custom_kind

The kind as the manifest names it, for a custom policy.

warn_within

How many samples ahead to warn, for a level policy.

rising

Whether a rise rather than a fall is watched, for a surge policy.

params

Every field the manifest carried beside a custom kind, as numbers, flags, and text by name, or None for a built-in kind.

class Controller:

The decision logic a profile assembles.

A controller carries state between readings, because a level estimate and a rate of change both need the previous sample, so evaluate readings through one controller in the order they were taken.

def setpoint(setpoint, hysteresis, cooling, safe_band):

Holds a reading near a setpoint by switching an output on and off.

def level(empty, warn_within):

Warns before a falling level reaches empty.

def surge(rising, limit):

Warns when a reading changes faster than a limit.

def monitor():

Reports readings without judging them.

def evaluate(self, /, reading):

Decides what one reading calls for.

@dataclass(frozen=True)
class CustomAlert:
38@dataclass(frozen=True)
39class CustomAlert:
40    """A condition a policy of the program's own raises, named by a code it chose."""
41
42    #: The condition's name, such as ``FrostRisk``.
43    code: str
44    #: The measurement behind it.
45    value: float
46    #: Always ``Custom``, as the built-in alerts name their own kind.
47    kind: str = "Custom"

A condition a policy of the program's own raises, named by a code it chose.

CustomAlert(code: str, value: float, kind: str = 'Custom')
code: str
value: float
kind: str = 'Custom'
@dataclass(frozen=True)
class Decision:
50@dataclass(frozen=True)
51class Decision:
52    """What a policy of the program's own decided about one reading."""
53
54    #: The setting the output should take, or ``None`` to leave it alone.
55    actuator: Optional[bool] = None
56    #: A condition the reading raised, or ``None``.
57    alert: Optional[CustomAlert] = None

What a policy of the program's own decided about one reading.

Decision( actuator: Optional[bool] = None, alert: Optional[CustomAlert] = None)
actuator: Optional[bool] = None
alert: Optional[CustomAlert] = None
class ElementSpec:

A custom sensor or node stat a profile contributes to the dashboard.

The graphic is named as a manifest names it (droplet, gauge, ...); the Viz enum in the facade lists the choices. A band is (low, high) in the element's unit, and scope names the link kinds whose groups the element is offered on, or None for every group.

scope

The link kinds whose groups this element is offered on; None means every group.

span

Whether the element's tile spans two columns.

unit

The canonical unit name, such as ntu, ph, or count.

label

A human-readable fallback label, shown when no localized label applies.

key

The stable, language-neutral element key, such as water_turbidity.

band

The safe band (low, high) in the element's unit.

labels

Per-locale labels, keyed by locale tag (en, sw, ...).

stat

Whether this is a node or network stat rather than a measurement of the world.

value

A starting numeric value for the add-sensor dialog.

state

A starting discrete state code, such as state.closed, for a non-numeric element.

viz

The graphic this element is drawn with, by its manifest name.

class Node:
112class Node:
113    """A profile assembled around the parts that make it run.
114
115    Each :meth:`tick` reads, lets the profile's policy decide, switches the output when the
116    policy calls for it, and publishes the reading to the profile's topic. :meth:`run`
117    repeats that at the cadence the profile's power schedule sets for the battery's charge.
118    """
119
120    def __init__(
121        self,
122        profile: Profile,
123        *,
124        read: Callable[[], Union[float, Awaitable[float]]],
125        link: Link,
126        drive: Optional[Callable[[bool], Any]] = None,
127        policy: Union[Policy, PolicyRegistry, None] = None,
128        encode: Optional[Callable[[float], Payload]] = None,
129    ) -> None:
130        """Assemble a node.
131
132        :param profile: The profile the node runs.
133        :param read: Takes one reading, in the unit the profile reads.
134        :param link: The link each reading is published over, connected before the node
135            runs.
136        :param drive: Switches the output a profile drives; required for a setpoint
137            profile.
138        :param policy: What decides each reading: the profile's own controller unless
139            given, a policy of the program's own, or a registry that resolves the
140            profile's control kind.
141        :param encode: Writes a reading as the payload published; the number as JSON text
142            unless given.
143        :raises PamojaError: When the profile drives an output and no ``drive`` is given,
144            or when its control kind is custom and no policy or registry decides it.
145        """
146        self.profile = profile
147        self._read = read
148        self._link = link
149        self._drive = drive
150        self._encode = encode or json.dumps
151        if isinstance(policy, PolicyRegistry):
152            self._policy = policy.resolve(profile)
153        else:
154            self._policy = policy if policy is not None else profile.controller()
155        if profile.control.kind == "Setpoint" and drive is None:
156            raise PamojaError(
157                f"the profile `{profile.name}` switches an output, so the node needs `drive`"
158            )
159        self._plan = profile.power_plan()
160        self._mode: Optional[str] = None
161
162    @property
163    def power_mode(self) -> Optional[str]:
164        """The power mode the last :meth:`schedule` chose, or ``None`` before the first."""
165        return self._mode
166
167    async def tick(self) -> Tick:
168        """Run one read, decide, act, and publish cycle.
169
170        :returns: The reading and what the policy decided about it.
171        :raises Exception: What the reading, the output, or the link raises.
172        """
173        reading = await _settle(self._read())
174        reaction = self._policy.evaluate(reading)
175        if reaction.actuator is not None and self._drive is not None:
176            await _settle(self._drive(reaction.actuator))
177        await self._link.send(self.profile.topic, self._encode(reading))
178        return Tick(reading, reaction)
179
180    def schedule(self, charge: float, charging: bool = False) -> tuple[str, float]:
181        """Say what power mode the battery's charge puts the node in and how long to wait
182        before the next tick.
183
184        The node remembers the mode it chose, so a charge hovering at a threshold keeps the
185        slower cadence until it clears the schedule's hysteresis.
186
187        :param charge: The state of charge, from 0 to 1.
188        :param charging: Whether the panel is delivering charge.
189        :returns: The mode, and the wait in seconds.
190        """
191        if self._mode is None:
192            mode = self._plan.mode_while_charging(charge, charging)
193        else:
194            mode = self._plan.next_mode_while_charging(self._mode, charge, charging)
195        self._mode = mode
196        return mode, self._plan.interval_for_us(mode) / 1_000_000
197
198    async def run(
199        self,
200        *,
201        battery: Optional[Callable[[], Any]] = None,
202        on_tick: Optional[Callable[[Tick], Any]] = None,
203        on_error: Optional[Callable[[BaseException], Any]] = None,
204        ticks: Optional[int] = None,
205        wait: Optional[Callable[[float], Awaitable[None]]] = None,
206    ) -> None:
207        """Tick, then wait the interval the battery's charge calls for, until cancelled.
208
209        Cancel the task running it to stop; the tick under way is abandoned where it
210        stands.
211
212        :param battery: Reads the battery before each wait, as a charge from 0 to 1 or a
213            ``(charge, charging)`` pair; a node without one samples at the active cadence.
214        :param on_tick: Hears each tick, such as to log it or to act on an alert.
215        :param on_error: Hears a tick that failed. The loop carries on at the same cadence;
216            without one, the failure ends the loop and is raised.
217        :param ticks: How many ticks to run before returning; the loop runs until cancelled
218            unless given.
219        :param wait: Waits between ticks, given seconds; :func:`asyncio.sleep` unless given,
220            which a test replaces to run at once.
221        """
222        pause = wait or asyncio.sleep
223        count = 0
224        while ticks is None or count < ticks:
225            try:
226                tick = await self.tick()
227                if on_tick is not None:
228                    await _settle(on_tick(tick))
229            except Exception as error:
230                if on_error is None:
231                    raise
232                await _settle(on_error(error))
233            charge = 1.0 if battery is None else await _settle(battery())
234            charging = False
235            if isinstance(charge, tuple):
236                charge, charging = charge
237            _, seconds = self.schedule(charge, charging)
238            count += 1
239            if ticks is None or count < ticks:
240                await pause(seconds)

A profile assembled around the parts that make it run.

Each tick() reads, lets the profile's policy decide, switches the output when the policy calls for it, and publishes the reading to the profile's topic. run() repeats that at the cadence the profile's power schedule sets for the battery's charge.

Node( profile: Profile, *, read: Callable[[], Union[float, Awaitable[float]]], link: Link, drive: Optional[Callable[[bool], Any]] = None, policy: Union[Policy, PolicyRegistry, NoneType] = None, encode: Optional[Callable[[float], Union[str, bytes]]] = None)
120    def __init__(
121        self,
122        profile: Profile,
123        *,
124        read: Callable[[], Union[float, Awaitable[float]]],
125        link: Link,
126        drive: Optional[Callable[[bool], Any]] = None,
127        policy: Union[Policy, PolicyRegistry, None] = None,
128        encode: Optional[Callable[[float], Payload]] = None,
129    ) -> None:
130        """Assemble a node.
131
132        :param profile: The profile the node runs.
133        :param read: Takes one reading, in the unit the profile reads.
134        :param link: The link each reading is published over, connected before the node
135            runs.
136        :param drive: Switches the output a profile drives; required for a setpoint
137            profile.
138        :param policy: What decides each reading: the profile's own controller unless
139            given, a policy of the program's own, or a registry that resolves the
140            profile's control kind.
141        :param encode: Writes a reading as the payload published; the number as JSON text
142            unless given.
143        :raises PamojaError: When the profile drives an output and no ``drive`` is given,
144            or when its control kind is custom and no policy or registry decides it.
145        """
146        self.profile = profile
147        self._read = read
148        self._link = link
149        self._drive = drive
150        self._encode = encode or json.dumps
151        if isinstance(policy, PolicyRegistry):
152            self._policy = policy.resolve(profile)
153        else:
154            self._policy = policy if policy is not None else profile.controller()
155        if profile.control.kind == "Setpoint" and drive is None:
156            raise PamojaError(
157                f"the profile `{profile.name}` switches an output, so the node needs `drive`"
158            )
159        self._plan = profile.power_plan()
160        self._mode: Optional[str] = None

Assemble a node.

Parameters
  • profile: The profile the node runs.
  • read: Takes one reading, in the unit the profile reads.
  • link: The link each reading is published over, connected before the node runs.
  • drive: Switches the output a profile drives; required for a setpoint profile.
  • policy: What decides each reading: the profile's own controller unless given, a policy of the program's own, or a registry that resolves the profile's control kind.
  • encode: Writes a reading as the payload published; the number as JSON text unless given.
Raises
  • PamojaError: When the profile drives an output and no drive is given, or when its control kind is custom and no policy or registry decides it.
profile
power_mode: Optional[str]
162    @property
163    def power_mode(self) -> Optional[str]:
164        """The power mode the last :meth:`schedule` chose, or ``None`` before the first."""
165        return self._mode

The power mode the last schedule() chose, or None before the first.

async def tick(self) -> Tick:
167    async def tick(self) -> Tick:
168        """Run one read, decide, act, and publish cycle.
169
170        :returns: The reading and what the policy decided about it.
171        :raises Exception: What the reading, the output, or the link raises.
172        """
173        reading = await _settle(self._read())
174        reaction = self._policy.evaluate(reading)
175        if reaction.actuator is not None and self._drive is not None:
176            await _settle(self._drive(reaction.actuator))
177        await self._link.send(self.profile.topic, self._encode(reading))
178        return Tick(reading, reaction)

Run one read, decide, act, and publish cycle.

:returns: The reading and what the policy decided about it.

Raises
  • Exception: What the reading, the output, or the link raises.
def schedule(self, charge: float, charging: bool = False) -> tuple[str, float]:
180    def schedule(self, charge: float, charging: bool = False) -> tuple[str, float]:
181        """Say what power mode the battery's charge puts the node in and how long to wait
182        before the next tick.
183
184        The node remembers the mode it chose, so a charge hovering at a threshold keeps the
185        slower cadence until it clears the schedule's hysteresis.
186
187        :param charge: The state of charge, from 0 to 1.
188        :param charging: Whether the panel is delivering charge.
189        :returns: The mode, and the wait in seconds.
190        """
191        if self._mode is None:
192            mode = self._plan.mode_while_charging(charge, charging)
193        else:
194            mode = self._plan.next_mode_while_charging(self._mode, charge, charging)
195        self._mode = mode
196        return mode, self._plan.interval_for_us(mode) / 1_000_000

Say what power mode the battery's charge puts the node in and how long to wait before the next tick.

The node remembers the mode it chose, so a charge hovering at a threshold keeps the slower cadence until it clears the schedule's hysteresis.

Parameters
  • charge: The state of charge, from 0 to 1.
  • charging: Whether the panel is delivering charge. :returns: The mode, and the wait in seconds.
async def run( self, *, battery: Optional[Callable[[], Any]] = None, on_tick: Optional[Callable[[Tick], Any]] = None, on_error: Optional[Callable[[BaseException], Any]] = None, ticks: Optional[int] = None, wait: Optional[Callable[[float], Awaitable[NoneType]]] = None) -> None:
198    async def run(
199        self,
200        *,
201        battery: Optional[Callable[[], Any]] = None,
202        on_tick: Optional[Callable[[Tick], Any]] = None,
203        on_error: Optional[Callable[[BaseException], Any]] = None,
204        ticks: Optional[int] = None,
205        wait: Optional[Callable[[float], Awaitable[None]]] = None,
206    ) -> None:
207        """Tick, then wait the interval the battery's charge calls for, until cancelled.
208
209        Cancel the task running it to stop; the tick under way is abandoned where it
210        stands.
211
212        :param battery: Reads the battery before each wait, as a charge from 0 to 1 or a
213            ``(charge, charging)`` pair; a node without one samples at the active cadence.
214        :param on_tick: Hears each tick, such as to log it or to act on an alert.
215        :param on_error: Hears a tick that failed. The loop carries on at the same cadence;
216            without one, the failure ends the loop and is raised.
217        :param ticks: How many ticks to run before returning; the loop runs until cancelled
218            unless given.
219        :param wait: Waits between ticks, given seconds; :func:`asyncio.sleep` unless given,
220            which a test replaces to run at once.
221        """
222        pause = wait or asyncio.sleep
223        count = 0
224        while ticks is None or count < ticks:
225            try:
226                tick = await self.tick()
227                if on_tick is not None:
228                    await _settle(on_tick(tick))
229            except Exception as error:
230                if on_error is None:
231                    raise
232                await _settle(on_error(error))
233            charge = 1.0 if battery is None else await _settle(battery())
234            charging = False
235            if isinstance(charge, tuple):
236                charge, charging = charge
237            _, seconds = self.schedule(charge, charging)
238            count += 1
239            if ticks is None or count < ticks:
240                await pause(seconds)

Tick, then wait the interval the battery's charge calls for, until cancelled.

Cancel the task running it to stop; the tick under way is abandoned where it stands.

Parameters
  • battery: Reads the battery before each wait, as a charge from 0 to 1 or a (charge, charging) pair; a node without one samples at the active cadence.
  • on_tick: Hears each tick, such as to log it or to act on an alert.
  • on_error: Hears a tick that failed. The loop carries on at the same cadence; without one, the failure ends the loop and is raised.
  • ticks: How many ticks to run before returning; the loop runs until cancelled unless given.
  • wait: Waits between ticks, given seconds; asyncio.sleep() unless given, which a test replaces to run at once.
class Policy(typing.Protocol):
30class Policy(Protocol):
31    """What decides each reading: a profile's built-in ``Controller``, or a policy of the
32    program's own, which returns a :class:`Decision`."""
33
34    def evaluate(self, reading: float) -> Any:
35        """Decide what one reading calls for."""

What decides each reading: a profile's built-in Controller, or a policy of the program's own, which returns a Decision.

Policy(*args, **kwargs)
1968def _no_init_or_replace_init(self, *args, **kwargs):
1969    cls = type(self)
1970
1971    if cls._is_protocol:
1972        raise TypeError('Protocols cannot be instantiated')
1973
1974    # Already using a custom `__init__`. No need to calculate correct
1975    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1976    if cls.__init__ is not _no_init_or_replace_init:
1977        return
1978
1979    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1980    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1981    # searches for a proper new `__init__` in the MRO. The new `__init__`
1982    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1983    # instantiation of the protocol subclass will thus use the new
1984    # `__init__` and no longer call `_no_init_or_replace_init`.
1985    for base in cls.__mro__:
1986        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1987        if init is not _no_init_or_replace_init:
1988            cls.__init__ = init
1989            break
1990    else:
1991        # should not happen
1992        cls.__init__ = object.__init__
1993
1994    cls.__init__(self, *args, **kwargs)
def evaluate(self, reading: float) -> Any:
34    def evaluate(self, reading: float) -> Any:
35        """Decide what one reading calls for."""

Decide what one reading calls for.

class PolicyRegistry:
 70class PolicyRegistry:
 71    """What resolves a profile's control kind to the code that decides it: a built-in kind
 72    to its ``Controller``, and a kind the library never shipped to the policy the factory
 73    registered under its name builds. One program then runs any manifest its registry
 74    covers."""
 75
 76    def __init__(self) -> None:
 77        self._factories: dict[str, Callable[[Params], Policy]] = {}
 78
 79    def register(self, kind: str, factory: Callable[[Params], Policy]) -> "PolicyRegistry":
 80        """Register the factory for a custom kind, replacing one of the same name.
 81
 82        :param kind: The kind as a manifest names it, such as ``frost_guard``.
 83        :param factory: Builds the policy from the parameters beside the kind.
 84        :returns: The registry, for chaining.
 85        """
 86        self._factories[kind] = factory
 87        return self
 88
 89    @property
 90    def kinds(self) -> list[str]:
 91        """The custom kinds registered, in name order."""
 92        return sorted(self._factories)
 93
 94    def resolve(self, profile: Profile) -> Policy:
 95        """Resolve a profile's control kind to the policy that decides it.
 96
 97        :param profile: The profile whose kind is resolved.
 98        :returns: A fresh policy, ready to evaluate readings.
 99        :raises PamojaError: When the kind is custom and no factory is registered for it,
100            naming the kind and the one it was probably meant to be.
101        """
102        control = profile.control
103        if control.kind != "Custom":
104            return profile.controller()
105        kind = control.custom_kind or ""
106        factory = self._factories.get(kind)
107        if factory is None:
108            raise PamojaError(unresolved(kind, self.kinds))
109        return factory(dict(control.params or {}))

What resolves a profile's control kind to the code that decides it: a built-in kind to its Controller, and a kind the library never shipped to the policy the factory registered under its name builds. One program then runs any manifest its registry covers.

def register( self, kind: str, factory: Callable[[dict[str, Union[float, bool, str]]], Policy]) -> PolicyRegistry:
79    def register(self, kind: str, factory: Callable[[Params], Policy]) -> "PolicyRegistry":
80        """Register the factory for a custom kind, replacing one of the same name.
81
82        :param kind: The kind as a manifest names it, such as ``frost_guard``.
83        :param factory: Builds the policy from the parameters beside the kind.
84        :returns: The registry, for chaining.
85        """
86        self._factories[kind] = factory
87        return self

Register the factory for a custom kind, replacing one of the same name.

Parameters
  • kind: The kind as a manifest names it, such as frost_guard.
  • factory: Builds the policy from the parameters beside the kind. :returns: The registry, for chaining.
kinds: list[str]
89    @property
90    def kinds(self) -> list[str]:
91        """The custom kinds registered, in name order."""
92        return sorted(self._factories)

The custom kinds registered, in name order.

def resolve(self, profile: Profile) -> Policy:
 94    def resolve(self, profile: Profile) -> Policy:
 95        """Resolve a profile's control kind to the policy that decides it.
 96
 97        :param profile: The profile whose kind is resolved.
 98        :returns: A fresh policy, ready to evaluate readings.
 99        :raises PamojaError: When the kind is custom and no factory is registered for it,
100            naming the kind and the one it was probably meant to be.
101        """
102        control = profile.control
103        if control.kind != "Custom":
104            return profile.controller()
105        kind = control.custom_kind or ""
106        factory = self._factories.get(kind)
107        if factory is None:
108            raise PamojaError(unresolved(kind, self.kinds))
109        return factory(dict(control.params or {}))

Resolve a profile's control kind to the policy that decides it.

Parameters
  • profile: The profile whose kind is resolved. :returns: A fresh policy, ready to evaluate readings.
Raises
  • PamojaError: When the kind is custom and no factory is registered for it, naming the kind and the one it was probably meant to be.
class PowerScheduleSpec:

How often a node samples as its battery drains, in whole seconds.

active_secs

Seconds between samples at a healthy charge.

saver_secs

Seconds between samples while conserving.

hysteresis

How far above a threshold the charge must climb to leave the lower cadence.

saver_below

Enter the saver cadence below this state of charge.

critical_secs

Seconds between samples when critically low.

critical_below

Enter the critical cadence below this state of charge.

class Presentation:

How a profile presents itself on the dashboard: its custom elements, an optional theme, and the words for any state or event code it introduces.

elements

The custom sensors and node stats this profile contributes.

messages

The text for each code this profile introduces, keyed by the page's message key (state.flushing, event.filter_clog): a str for every locale, or a dict from locale tag to text.

theme

An optional theme that tints the dashboard.

class Profile:

A named, ready-to-run node assembled from pamoja capabilities.

def vaccine_fridge_monitor():

A cold-chain fridge monitor, which holds 5 C and flags an excursion.

def irrigation_node():

An irrigation node, which opens a valve as soil moisture falls.

def well_level():

A well-level monitor, which warns before a tank runs dry.

def flood_sensor():

A flood sensor, which warns when a level rises too fast.

def from_json(manifest):

Loads a profile from its JSON manifest.

Raises PamojaError if the manifest is malformed, or describes a profile no node could run.

def to_json(self, /):

Serializes this profile to its JSON manifest.

def with_reads(self, /, quantity, unit):

A copy of this profile that says what it reads.

Raises PamojaError when the quantity or unit is not lowercase words joined by underscores.

def with_description(self, /, description):

A copy of this profile carrying a description of what it is for.

def with_presentation(self, /, presentation):

A copy of this profile carrying a dashboard presentation.

Raises ValueError if an element names a graphic the dashboard does not draw, and PamojaError if the presentation holds something else the dashboard could not draw, such as a band whose low end comes second.

def power_plan(self, /):

The schedule assembled into the power governor, which says what mode a charge puts the node in and how long it waits between samples there.

def controller(self, /):

Builds the decision logic this profile describes.

Each call builds a new controller, so keep the one it returns for the life of the node: one built again for each reading forgets whether its output was on and what the reading before was.

Raises PamojaError when the profile names a custom control kind, which no built-in controller decides.

name

The profile's stable, human-readable name.

description

What the profile is for, in the words its manifest carries, or None.

reads

What the profile reads, the quantity and the unit its numbers are in, or None when the manifest does not say.

topic

The topic each reading is published to.

presentation

How the profile presents itself on the dashboard, or None when it declares nothing beyond the built-in set.

power

The sampling schedule kept as the battery drains.

control

The control policy applied to each reading.

class Reaction:

What a controller decided about one reading.

alert

The alert the reading raised, or None if it crossed nothing.

actuator

The setting the output should take, or None when the profile observes rather than controls.

class Reads:

What a profile reads: the quantity its control decides on, and the unit its numbers are in, both lowercase words joined by underscores.

quantity

The quantity, such as temperature or relative_humidity.

unit

The unit the profile's numbers are in, such as celsius or percent.

class RuleAction:

One thing a rule calls for, as the rule file writes it.

Only the attributes belonging to kind are set; the rest are None.

kind

drive to switch an output, or publish to send a message.

payload

The text to publish, for a publish.

actuator

The output to switch, for a drive.

topic

The topic to publish to, for a publish.

on

The setting to switch it to, for a drive.

class RuleActionKind(builtins.str, enum.Enum):
80class RuleActionKind(str, enum.Enum):
81    """What a rule calls for when its condition sets or clears."""
82
83    #: Switch the output the program holds under ``actuator``.
84    DRIVE = "drive"
85    #: Publish ``payload`` to ``topic``.
86    PUBLISH = "publish"

What a rule calls for when its condition sets or clears.

DRIVE = <RuleActionKind.DRIVE: 'drive'>
PUBLISH = <RuleActionKind.PUBLISH: 'publish'>
class RuleEngine:
 27class RuleEngine:
 28    """Runs a rule file off a link.
 29
 30    :meth:`listen` checks that every output a rule drives was given and subscribes to every
 31    watched topic; each :meth:`step` handles one message, switching the outputs by name and
 32    publishing over the same link; :meth:`run` repeats it until cancelled.
 33    """
 34
 35    def __init__(
 36        self,
 37        rules: Union[str, RuleEvaluator],
 38        link: ReceivingLink,
 39        *,
 40        actuators: Optional[dict[str, Callable[[bool], Any]]] = None,
 41        decode: Optional[Callable[[Any], float]] = None,
 42    ) -> None:
 43        """Assemble an engine.
 44
 45        :param rules: The rule file's text, or an evaluator already loaded from it.
 46        :param link: The link readings arrive on and actions publish over.
 47        :param actuators: The outputs the rules drive, each under the name the rule file
 48            gives it.
 49        :param decode: Reads a message as a reading; its ``number`` unless given, which
 50            raises ``ValueError`` for a payload that is not a number written out.
 51        :raises PamojaError: When the rule file is refused, with the rule and the reason.
 52        """
 53        self.evaluator = RuleEvaluator.from_json(rules) if isinstance(rules, str) else rules
 54        self._link = link
 55        self._actuators = dict(actuators or {})
 56        self._decode = decode or (lambda message: message.number)
 57
 58    @property
 59    def topics(self) -> list[str]:
 60        """The topics the rules watch, each once, in name order."""
 61        return self.evaluator.topics
 62
 63    @property
 64    def actuators(self) -> list[str]:
 65        """The outputs the rules drive, each once, in name order."""
 66        return self.evaluator.actuators
 67
 68    def is_set(self, rule: str) -> Optional[bool]:
 69        """Whether a rule's condition holds.
 70
 71        :param rule: The rule's name.
 72        :returns: The state, or ``None`` for a name no rule has.
 73        """
 74        return self.evaluator.is_set(rule)
 75
 76    async def listen(self) -> None:
 77        """Check the engine was given every output a rule drives, and subscribe to every
 78        watched topic.
 79
 80        :raises PamojaError: When a rule drives an output the engine was not given,
 81            naming it.
 82        """
 83        missing = [name for name in self.actuators if name not in self._actuators]
 84        if missing:
 85            raise PamojaError(
 86                "a rule drives `"
 87                + "`, `".join(missing)
 88                + "`, which the engine was not given under `actuators`"
 89            )
 90        for topic in self.topics:
 91            await self._link.subscribe(topic)
 92
 93    async def step(self, timeout: Optional[float] = None) -> Optional[list[RuleFired]]:
 94        """Wait for one message and run every rule that watches its topic.
 95
 96        :param timeout: How long to wait, in seconds; for good unless given.
 97        :returns: What fired, which is empty when the message set or cleared nothing, or
 98            ``None`` when no message arrived in time or the link has ended.
 99        :raises PamojaError: When a reading on a watched topic is not a finite number.
100        :raises Exception: What decoding or an action raises.
101        """
102        try:
103            message = await asyncio.wait_for(self._link.recv(), timeout)
104        except asyncio.TimeoutError:
105            return None
106        if message is None:
107            return None
108        if not self.evaluator.watches(message.topic):
109            return []
110        fired = self.evaluator.evaluate(message.topic, self._decode(message))
111        for one in fired:
112            for action in one.actions:
113                if action.kind == "drive":
114                    await _settle(self._actuators[action.actuator](bool(action.on)))
115                else:
116                    await self._link.send(action.topic, action.payload)
117        return fired
118
119    async def run(
120        self,
121        *,
122        on_fired: Optional[Callable[[list[RuleFired]], Any]] = None,
123        on_error: Optional[Callable[[BaseException], Any]] = None,
124        messages: Optional[int] = None,
125    ) -> None:
126        """Handle messages until cancelled.
127
128        :param on_fired: Hears each message that set or cleared a rule, with what fired.
129        :param on_error: Hears a message that could not be judged or an action that failed.
130            The loop carries on with the next message; without one, the failure ends the
131            loop and is raised.
132        :param messages: How many messages to handle before returning; the loop runs until
133            cancelled or the link ends unless given.
134        """
135        handled = 0
136        while messages is None or handled < messages:
137            try:
138                fired = await self.step()
139                if fired is None:
140                    return
141                handled += 1
142                if fired and on_fired is not None:
143                    await _settle(on_fired(fired))
144            except Exception as error:
145                handled += 1
146                if on_error is None:
147                    raise
148                await _settle(on_error(error))

Runs a rule file off a link.

listen() checks that every output a rule drives was given and subscribes to every watched topic; each step() handles one message, switching the outputs by name and publishing over the same link; run() repeats it until cancelled.

RuleEngine( rules: Union[str, RuleEvaluator], link: ReceivingLink, *, actuators: Optional[dict[str, Callable[[bool], Any]]] = None, decode: Optional[Callable[[Any], float]] = None)
35    def __init__(
36        self,
37        rules: Union[str, RuleEvaluator],
38        link: ReceivingLink,
39        *,
40        actuators: Optional[dict[str, Callable[[bool], Any]]] = None,
41        decode: Optional[Callable[[Any], float]] = None,
42    ) -> None:
43        """Assemble an engine.
44
45        :param rules: The rule file's text, or an evaluator already loaded from it.
46        :param link: The link readings arrive on and actions publish over.
47        :param actuators: The outputs the rules drive, each under the name the rule file
48            gives it.
49        :param decode: Reads a message as a reading; its ``number`` unless given, which
50            raises ``ValueError`` for a payload that is not a number written out.
51        :raises PamojaError: When the rule file is refused, with the rule and the reason.
52        """
53        self.evaluator = RuleEvaluator.from_json(rules) if isinstance(rules, str) else rules
54        self._link = link
55        self._actuators = dict(actuators or {})
56        self._decode = decode or (lambda message: message.number)

Assemble an engine.

Parameters
  • rules: The rule file's text, or an evaluator already loaded from it.
  • link: The link readings arrive on and actions publish over.
  • actuators: The outputs the rules drive, each under the name the rule file gives it.
  • decode: Reads a message as a reading; its number unless given, which raises ValueError for a payload that is not a number written out.
Raises
  • PamojaError: When the rule file is refused, with the rule and the reason.
evaluator
topics: list[str]
58    @property
59    def topics(self) -> list[str]:
60        """The topics the rules watch, each once, in name order."""
61        return self.evaluator.topics

The topics the rules watch, each once, in name order.

actuators: list[str]
63    @property
64    def actuators(self) -> list[str]:
65        """The outputs the rules drive, each once, in name order."""
66        return self.evaluator.actuators

The outputs the rules drive, each once, in name order.

def is_set(self, rule: str) -> Optional[bool]:
68    def is_set(self, rule: str) -> Optional[bool]:
69        """Whether a rule's condition holds.
70
71        :param rule: The rule's name.
72        :returns: The state, or ``None`` for a name no rule has.
73        """
74        return self.evaluator.is_set(rule)

Whether a rule's condition holds.

Parameters
  • rule: The rule's name. :returns: The state, or None for a name no rule has.
async def listen(self) -> None:
76    async def listen(self) -> None:
77        """Check the engine was given every output a rule drives, and subscribe to every
78        watched topic.
79
80        :raises PamojaError: When a rule drives an output the engine was not given,
81            naming it.
82        """
83        missing = [name for name in self.actuators if name not in self._actuators]
84        if missing:
85            raise PamojaError(
86                "a rule drives `"
87                + "`, `".join(missing)
88                + "`, which the engine was not given under `actuators`"
89            )
90        for topic in self.topics:
91            await self._link.subscribe(topic)

Check the engine was given every output a rule drives, and subscribe to every watched topic.

Raises
  • PamojaError: When a rule drives an output the engine was not given, naming it.
async def step(self, timeout: Optional[float] = None) -> Optional[list[RuleFired]]:
 93    async def step(self, timeout: Optional[float] = None) -> Optional[list[RuleFired]]:
 94        """Wait for one message and run every rule that watches its topic.
 95
 96        :param timeout: How long to wait, in seconds; for good unless given.
 97        :returns: What fired, which is empty when the message set or cleared nothing, or
 98            ``None`` when no message arrived in time or the link has ended.
 99        :raises PamojaError: When a reading on a watched topic is not a finite number.
100        :raises Exception: What decoding or an action raises.
101        """
102        try:
103            message = await asyncio.wait_for(self._link.recv(), timeout)
104        except asyncio.TimeoutError:
105            return None
106        if message is None:
107            return None
108        if not self.evaluator.watches(message.topic):
109            return []
110        fired = self.evaluator.evaluate(message.topic, self._decode(message))
111        for one in fired:
112            for action in one.actions:
113                if action.kind == "drive":
114                    await _settle(self._actuators[action.actuator](bool(action.on)))
115                else:
116                    await self._link.send(action.topic, action.payload)
117        return fired

Wait for one message and run every rule that watches its topic.

Parameters
  • timeout: How long to wait, in seconds; for good unless given. :returns: What fired, which is empty when the message set or cleared nothing, or None when no message arrived in time or the link has ended.
Raises
  • PamojaError: When a reading on a watched topic is not a finite number.
  • Exception: What decoding or an action raises.
async def run( self, *, on_fired: Optional[Callable[[list[RuleFired]], Any]] = None, on_error: Optional[Callable[[BaseException], Any]] = None, messages: Optional[int] = None) -> None:
119    async def run(
120        self,
121        *,
122        on_fired: Optional[Callable[[list[RuleFired]], Any]] = None,
123        on_error: Optional[Callable[[BaseException], Any]] = None,
124        messages: Optional[int] = None,
125    ) -> None:
126        """Handle messages until cancelled.
127
128        :param on_fired: Hears each message that set or cleared a rule, with what fired.
129        :param on_error: Hears a message that could not be judged or an action that failed.
130            The loop carries on with the next message; without one, the failure ends the
131            loop and is raised.
132        :param messages: How many messages to handle before returning; the loop runs until
133            cancelled or the link ends unless given.
134        """
135        handled = 0
136        while messages is None or handled < messages:
137            try:
138                fired = await self.step()
139                if fired is None:
140                    return
141                handled += 1
142                if fired and on_fired is not None:
143                    await _settle(on_fired(fired))
144            except Exception as error:
145                handled += 1
146                if on_error is None:
147                    raise
148                await _settle(on_error(error))

Handle messages until cancelled.

Parameters
  • on_fired: Hears each message that set or cleared a rule, with what fired.
  • on_error: Hears a message that could not be judged or an action that failed. The loop carries on with the next message; without one, the failure ends the loop and is raised.
  • messages: How many messages to handle before returning; the loop runs until cancelled or the link ends unless given.
class RuleEvaluator:

The decisions a rule file makes, reading by reading, with no link and no outputs of its own.

def from_json(text):

Loads a rule file and arms its rules, every condition starting cleared.

Raises PamojaError if the text is not a rule file, or holds a rule no engine could run, such as one that watches a filter or has nothing to do.

def to_json(self, /):

Writes the rules back out as the file a fleet shares.

def evaluate(self, /, topic, reading):

Judges one reading from one topic against every rule that watches it, and returns what fired in the order the rules are listed: empty when no rule watches the topic or the reading changed nothing.

Raises PamojaError if a rule watches the topic and the reading is not a finite number.

def watches(self, /, topic):

Whether any rule watches a topic, so the program knows whether to decode a message.

def is_set(self, /, rule):

Whether a rule's condition currently holds, or None for a name no rule has.

topics

The topics the rules watch, each once, in name order: the topics to subscribe to.

actuators

The actuators the rules drive, each once, in name order: the outputs the program has to have.

class RuleFired:

What one rule did with one reading.

edge

set when the rule's condition became true, cleared when it stopped holding.

rule

The rule's name.

reading

The reading that did it.

actions

What the rule calls for on this edge: its then actions when it set and its otherwise actions when it cleared, in the order the file gives them.

class Theme:

The theme tokens a profile sets on the dashboard; each is any CSS color.

alarm

The alarm status color.

ok

The healthy status color, which also tints an in-band gauge.

warn

The warning status color.

accent

The brand and interaction accent.

track

The unfilled track color behind gauges and bars.

@dataclass(frozen=True)
class Tick:
60@dataclass(frozen=True)
61class Tick:
62    """One reading a node took, and what its policy decided about it."""
63
64    #: The reading, in the unit the profile reads.
65    reading: float
66    #: The output setting and the alert the policy decided on.
67    reaction: Any

One reading a node took, and what its policy decided about it.

Tick(reading: float, reaction: Any)
reading: float
reaction: Any
class Viz(builtins.str, enum.Enum):
 89class Viz(str, enum.Enum):
 90    """The graphic a dashboard draws an element with, named by the instrument rather
 91    than the quantity. The values are the ones a manifest carries."""
 92
 93    #: A rolling sparkline of recent values.
 94    SPARK = "spark"
 95    #: A 270-degree arch gauge, for a fraction or percentage.
 96    GAUGE = "gauge"
 97    #: A half-dial with a needle, for a pressure or flow reading.
 98    DIAL = "dial"
 99    #: A horizontal bar with a safe-band tick, for a level or stock.
100    BAR = "bar"
101    #: A thermometer, for a temperature.
102    THERMOMETER = "thermometer"
103    #: A liquid-filled droplet, for humidity or moisture.
104    DROPLET = "droplet"
105    #: A segmented battery cell, for a state of charge or voltage.
106    BATTERY = "battery"
107    #: An anemometer, for wind speed.
108    WIND = "wind"
109    #: A sun whose corona grows with the reading, for illuminance.
110    SUN = "sun"
111    #: An acoustic waveform, for sound level or an acoustic event.
112    WAVE = "wave"
113    #: A labeled state chip, lit when the state reads as on.
114    SWITCH = "switch"
115    #: A pipe valve, open along the flow or closed across it.
116    VALVE = "valve"
117    #: A row of hash-chained blocks, for a tamper-evident record count.
118    CHAIN = "chain"
119    #: A neighbor-mesh topology map, for a mesh node's peers.
120    MESH = "mesh"
121    #: A plain numeric counter, for a node or network stat.
122    COUNT = "count"

The graphic a dashboard draws an element with, named by the instrument rather than the quantity. The values are the ones a manifest carries.

SPARK = <Viz.SPARK: 'spark'>
GAUGE = <Viz.GAUGE: 'gauge'>
DIAL = <Viz.DIAL: 'dial'>
BAR = <Viz.BAR: 'bar'>
THERMOMETER = <Viz.THERMOMETER: 'thermometer'>
DROPLET = <Viz.DROPLET: 'droplet'>
BATTERY = <Viz.BATTERY: 'battery'>
WIND = <Viz.WIND: 'wind'>
SUN = <Viz.SUN: 'sun'>
WAVE = <Viz.WAVE: 'wave'>
SWITCH = <Viz.SWITCH: 'switch'>
VALVE = <Viz.VALVE: 'valve'>
CHAIN = <Viz.CHAIN: 'chain'>
MESH = <Viz.MESH: 'mesh'>
COUNT = <Viz.COUNT: 'count'>