iot_core/binding.rs
1//! Bridge from the protocol-agnostic [`crate::path::PropertyPath`] to the
2//! concrete address used on the wire.
3//!
4//! Each protocol crate consumes the matching [`ProtocolBinding`] variant
5//! through its `IotClient` impl. The gateway crate (`iot-gateway`) walks a
6//! [`ThingMapping`] to bridge protocols.
7//!
8//! These types are deliberately *value*-shaped: no methods that touch the
9//! network, no async. They serialise cleanly via serde so a deployment can
10//! ship the mapping as TOML/YAML/JSON.
11
12#[cfg(feature = "alloc")]
13use alloc::collections::BTreeMap;
14
15use smol_str::SmolStr;
16
17use crate::path::PropertyPath;
18use crate::thing::ThingId;
19
20/// MQTT QoS level.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[repr(u8)]
24pub enum Qos {
25 /// At most once — fire and forget.
26 #[default]
27 AtMostOnce = 0,
28 /// At least once — acknowledged delivery.
29 AtLeastOnce = 1,
30 /// Exactly once — full QoS2 handshake.
31 ExactlyOnce = 2,
32}
33
34/// How an MQTT payload encodes a [`crate::Value`].
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum PayloadCodec {
38 /// JSON object `{"value": ...}` — default for cloud bridges.
39 #[default]
40 Json,
41 /// CBOR-encoded value — compact for cellular links.
42 Cbor,
43 /// Raw bytes / utf-8 string — payload IS the value.
44 Raw,
45}
46
47/// Modbus register area.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub enum RegisterArea {
51 /// Read/write single bit (function 0x01 read, 0x05/0x0F write).
52 Coil,
53 /// Read-only bit (function 0x02).
54 DiscreteInput,
55 /// Read/write 16-bit register (function 0x03 read, 0x06/0x10 write).
56 HoldingRegister,
57 /// Read-only 16-bit register (function 0x04).
58 InputRegister,
59}
60
61/// How a multi-register Modbus value is laid out in memory.
62///
63/// Variants describe both endianness *and* word order — Modbus does not
64/// fix this, every vendor does it differently.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub enum ByteOrder {
68 /// Big-endian, big-word-first (`AB CD`). Most common.
69 #[default]
70 BigEndian,
71 /// Little-endian, little-word-first (`DC BA`).
72 LittleEndian,
73 /// Mid-big — bytes big-endian, words swapped (`CD AB`).
74 MidBigEndian,
75 /// Mid-little — bytes little-endian, words swapped (`BA DC`).
76 MidLittleEndian,
77}
78
79/// How to interpret one or more registers as a typed value.
80#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub enum DataType {
83 /// 16-bit unsigned (1 register).
84 U16,
85 /// 16-bit signed (1 register).
86 I16,
87 /// 32-bit unsigned (2 registers).
88 U32,
89 /// 32-bit signed (2 registers).
90 I32,
91 /// IEEE-754 single (2 registers).
92 F32,
93 /// IEEE-754 double (4 registers).
94 F64,
95 /// `len` bytes, packed two per register, ASCII / UTF-8 — the integer
96 /// is the byte length of the string.
97 String(u16),
98 /// Raw byte blob, `len` bytes (`len/2` registers).
99 Bytes(u16),
100}
101
102/// Content-Format option used by CoAP — RFC 7252 §12.3.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[repr(u16)]
106pub enum ContentFormat {
107 /// `text/plain;charset=utf-8`.
108 #[default]
109 TextPlain = 0,
110 /// `application/octet-stream`.
111 OctetStream = 42,
112 /// `application/json`.
113 Json = 50,
114 /// `application/cbor`.
115 Cbor = 60,
116}
117
118/// Concrete protocol address backing one [`PropertyPath`].
119///
120/// Each protocol crate handles exactly one variant — the others are
121/// transparent to it. The variants are gated by the corresponding feature
122/// flag in the umbrella crate but are always *defined* here (unconditionally)
123/// so that the gateway can walk a heterogeneous mapping.
124#[derive(Debug, Clone, PartialEq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
127pub enum ProtocolBinding {
128 /// MQTT topic + QoS + payload encoding.
129 Mqtt {
130 /// Topic to publish reported values on.
131 publish_topic: SmolStr,
132 /// Topic to subscribe to incoming desired values.
133 subscribe_topic: SmolStr,
134 /// QoS level.
135 qos: Qos,
136 /// Payload codec.
137 codec: PayloadCodec,
138 },
139 /// Modbus register address.
140 Modbus {
141 /// Slave / unit identifier.
142 unit_id: u8,
143 /// Coil / discrete-input / holding / input register area.
144 area: RegisterArea,
145 /// Starting register number (zero-based on the wire).
146 address: u16,
147 /// Typed view over the register(s).
148 data_type: DataType,
149 /// Byte / word order.
150 byte_order: ByteOrder,
151 },
152 /// CoAP resource URI.
153 Coap {
154 /// Full `coap://host[:port]/path` URI.
155 uri: SmolStr,
156 /// Content-Format option to send.
157 content_format: ContentFormat,
158 /// Whether the resource supports `Observe` (RFC 7641).
159 observable: bool,
160 },
161 /// OPC UA node — `node_id` is held as the canonical text form
162 /// (`ns=2;s=PumpTemp`). The OPC UA crate parses it lazily.
163 OpcUa {
164 /// Canonical text form NodeId.
165 node_id: SmolStr,
166 /// Attribute id (Value = 13, ...). Default = Value.
167 attribute: u32,
168 },
169}
170
171/// Mapping of every [`PropertyPath`] in one Thing onto its protocol
172/// address.
173///
174/// In `no_std + alloc`, `BTreeMap` is the canonical container; it is
175/// `O(log n)` and deterministic, both of which matter for industrial
176/// devices.
177#[cfg(feature = "alloc")]
178#[derive(Debug, Clone, PartialEq)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180pub struct ThingMapping {
181 /// Owning Thing.
182 #[cfg_attr(feature = "serde", serde(rename = "thingId"))]
183 pub thing_id: ThingId,
184 /// Address of every bound property.
185 pub bindings: BTreeMap<PropertyPath, ProtocolBinding>,
186}
187
188#[cfg(feature = "alloc")]
189impl ThingMapping {
190 /// Construct an empty mapping.
191 pub fn new(thing_id: ThingId) -> Self {
192 Self {
193 thing_id,
194 bindings: BTreeMap::new(),
195 }
196 }
197
198 /// Bind one path. Returns the previous binding if any.
199 pub fn bind(
200 &mut self,
201 path: PropertyPath,
202 binding: ProtocolBinding,
203 ) -> Option<ProtocolBinding> {
204 self.bindings.insert(path, binding)
205 }
206
207 /// Look up a binding.
208 pub fn get(&self, path: &PropertyPath) -> Option<&ProtocolBinding> {
209 self.bindings.get(path)
210 }
211}
212
213#[cfg(test)]
214#[cfg(feature = "alloc")]
215mod tests {
216 use super::*;
217 use crate::thing::{FeatureId, PropertyId};
218
219 #[test]
220 fn bind_and_lookup() {
221 let mut m = ThingMapping::new(ThingId::new("acme:pump-7"));
222 let p = PropertyPath::new(
223 ThingId::new("acme:pump-7"),
224 FeatureId::new("temperature"),
225 PropertyId::new("value"),
226 );
227 let b = ProtocolBinding::Modbus {
228 unit_id: 1,
229 area: RegisterArea::HoldingRegister,
230 address: 40001 - 40001, // zero-based on wire
231 data_type: DataType::F32,
232 byte_order: ByteOrder::BigEndian,
233 };
234 assert!(m.bind(p.clone(), b.clone()).is_none());
235 assert_eq!(m.get(&p), Some(&b));
236 }
237}