pamoja_dashboard/command.rs
1//! The control contract: the authenticated actions a client can ask a node to take.
2//!
3//! Reading the dashboard needs no command; changing the node does. A [`Command`] arrives
4//! over the authenticated `POST /command` path (see the serving layer) and is dispatched
5//! to the [`StateSource`](crate::StateSource), which is the only thing that can move an
6//! actuator or change the fleet. The wire form is a serde-tagged object, so the page
7//! sends `{"type":"actuate", ...}` for a discrete actuator and `{"type":"set", ...}` for
8//! a numeric one.
9
10use serde::Deserialize;
11
12use crate::state::{Group, Sensor};
13
14/// A control action a client asks the node to take.
15///
16/// The provisioning variants carry the group or sensor the client built, so the device
17/// records the structure the operator described; the device owns and shares it across
18/// every client.
19// A command is decoded once per request and queued a handful at a time, so the sensor
20// the add variant carries stays inline rather than boxed for the wire type's sake.
21#[allow(clippy::large_enum_variant)]
22#[derive(Debug, Clone, Deserialize)]
23#[serde(tag = "type", rename_all = "camelCase")]
24pub enum Command {
25 /// Set a discrete actuator to one of its actions, such as opening a valve.
26 Actuate {
27 /// The actuator's `"groupId/sensorId"` path.
28 target: String,
29 /// The action to apply, one of the reading's advertised actions (`"open"`).
30 action: String,
31 },
32 /// Set a numeric actuator to a value inside its advertised range, such as a pump
33 /// speed or a setpoint.
34 Set {
35 /// The actuator's `"groupId/sensorId"` path.
36 target: String,
37 /// The value to apply, in the reading's unit and inside its
38 /// [`range`](crate::Reading::range).
39 value: f32,
40 },
41 /// Add a group to an organization.
42 AddGroup {
43 /// The organization's id.
44 org: String,
45 /// The group to add.
46 group: Group,
47 },
48 /// Remove a group by id.
49 RemoveGroup {
50 /// The group's id.
51 id: String,
52 },
53 /// Add a sensor to a group.
54 AddSensor {
55 /// The group's id.
56 group: String,
57 /// The sensor to add.
58 sensor: Sensor,
59 /// An optional gateway-defined hardware binding the device uses to find the sensor,
60 /// such as `"i2c:0x76"`, `"gpio:4"`, or `"lora:ab12"`. The dashboard only carries it
61 /// through; binding a real driver is the gateway's job when it drains the command.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 binding: Option<String>,
64 },
65 /// Remove a sensor by its `"groupId/sensorId"` path.
66 RemoveSensor {
67 /// The sensor's `"groupId/sensorId"` path.
68 target: String,
69 },
70}
71
72/// Why a command could not be carried out. The [`code`](CommandError::code) is a stable,
73/// language-neutral string the page localizes.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum CommandError {
76 /// The source does not handle this kind of command.
77 Unsupported,
78 /// No actuator or target matches the command.
79 UnknownTarget,
80 /// The target exists but does not accept the requested action.
81 InvalidAction,
82 /// The value lies outside the range the target advertises.
83 OutOfRange,
84 /// The sensor being added is not one this device supports, so it was not added. A real
85 /// device only accepts the sensor types it can actually bind a driver to.
86 UnknownSensor,
87}
88
89impl CommandError {
90 /// Returns the stable error code for this failure.
91 ///
92 /// # Returns
93 ///
94 /// A dotted, language-neutral code such as `"command.unknown_target"`.
95 pub fn code(self) -> &'static str {
96 match self {
97 CommandError::Unsupported => "command.unsupported",
98 CommandError::UnknownTarget => "command.unknown_target",
99 CommandError::InvalidAction => "command.invalid_action",
100 CommandError::OutOfRange => "command.out_of_range",
101 CommandError::UnknownSensor => "command.unknown_sensor",
102 }
103 }
104}