iot-core 0.0.1

Core types for the iot-protocols SDK: Thing model, IotClient trait, errors, paths, protocol bindings.
Documentation
//! Bridge from the protocol-agnostic [`crate::path::PropertyPath`] to the
//! concrete address used on the wire.
//!
//! Each protocol crate consumes the matching [`ProtocolBinding`] variant
//! through its `IotClient` impl. The gateway crate (`iot-gateway`) walks a
//! [`ThingMapping`] to bridge protocols.
//!
//! These types are deliberately *value*-shaped: no methods that touch the
//! network, no async. They serialise cleanly via serde so a deployment can
//! ship the mapping as TOML/YAML/JSON.

#[cfg(feature = "alloc")]
use alloc::collections::BTreeMap;

use smol_str::SmolStr;

use crate::path::PropertyPath;
use crate::thing::ThingId;

/// MQTT QoS level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum Qos {
    /// At most once — fire and forget.
    #[default]
    AtMostOnce = 0,
    /// At least once — acknowledged delivery.
    AtLeastOnce = 1,
    /// Exactly once — full QoS2 handshake.
    ExactlyOnce = 2,
}

/// How an MQTT payload encodes a [`crate::Value`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PayloadCodec {
    /// JSON object `{"value": ...}` — default for cloud bridges.
    #[default]
    Json,
    /// CBOR-encoded value — compact for cellular links.
    Cbor,
    /// Raw bytes / utf-8 string — payload IS the value.
    Raw,
}

/// Modbus register area.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum RegisterArea {
    /// Read/write single bit (function 0x01 read, 0x05/0x0F write).
    Coil,
    /// Read-only bit (function 0x02).
    DiscreteInput,
    /// Read/write 16-bit register (function 0x03 read, 0x06/0x10 write).
    HoldingRegister,
    /// Read-only 16-bit register (function 0x04).
    InputRegister,
}

/// How a multi-register Modbus value is laid out in memory.
///
/// Variants describe both endianness *and* word order — Modbus does not
/// fix this, every vendor does it differently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ByteOrder {
    /// Big-endian, big-word-first (`AB CD`). Most common.
    #[default]
    BigEndian,
    /// Little-endian, little-word-first (`DC BA`).
    LittleEndian,
    /// Mid-big — bytes big-endian, words swapped (`CD AB`).
    MidBigEndian,
    /// Mid-little — bytes little-endian, words swapped (`BA DC`).
    MidLittleEndian,
}

/// How to interpret one or more registers as a typed value.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DataType {
    /// 16-bit unsigned (1 register).
    U16,
    /// 16-bit signed (1 register).
    I16,
    /// 32-bit unsigned (2 registers).
    U32,
    /// 32-bit signed (2 registers).
    I32,
    /// IEEE-754 single (2 registers).
    F32,
    /// IEEE-754 double (4 registers).
    F64,
    /// `len` bytes, packed two per register, ASCII / UTF-8 — the integer
    /// is the byte length of the string.
    String(u16),
    /// Raw byte blob, `len` bytes (`len/2` registers).
    Bytes(u16),
}

/// Content-Format option used by CoAP — RFC 7252 §12.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u16)]
pub enum ContentFormat {
    /// `text/plain;charset=utf-8`.
    #[default]
    TextPlain = 0,
    /// `application/octet-stream`.
    OctetStream = 42,
    /// `application/json`.
    Json = 50,
    /// `application/cbor`.
    Cbor = 60,
}

/// Concrete protocol address backing one [`PropertyPath`].
///
/// Each protocol crate handles exactly one variant — the others are
/// transparent to it. The variants are gated by the corresponding feature
/// flag in the umbrella crate but are always *defined* here (unconditionally)
/// so that the gateway can walk a heterogeneous mapping.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
pub enum ProtocolBinding {
    /// MQTT topic + QoS + payload encoding.
    Mqtt {
        /// Topic to publish reported values on.
        publish_topic: SmolStr,
        /// Topic to subscribe to incoming desired values.
        subscribe_topic: SmolStr,
        /// QoS level.
        qos: Qos,
        /// Payload codec.
        codec: PayloadCodec,
    },
    /// Modbus register address.
    Modbus {
        /// Slave / unit identifier.
        unit_id: u8,
        /// Coil / discrete-input / holding / input register area.
        area: RegisterArea,
        /// Starting register number (zero-based on the wire).
        address: u16,
        /// Typed view over the register(s).
        data_type: DataType,
        /// Byte / word order.
        byte_order: ByteOrder,
    },
    /// CoAP resource URI.
    Coap {
        /// Full `coap://host[:port]/path` URI.
        uri: SmolStr,
        /// Content-Format option to send.
        content_format: ContentFormat,
        /// Whether the resource supports `Observe` (RFC 7641).
        observable: bool,
    },
    /// OPC UA node — `node_id` is held as the canonical text form
    /// (`ns=2;s=PumpTemp`). The OPC UA crate parses it lazily.
    OpcUa {
        /// Canonical text form NodeId.
        node_id: SmolStr,
        /// Attribute id (Value = 13, ...). Default = Value.
        attribute: u32,
    },
}

/// Mapping of every [`PropertyPath`] in one Thing onto its protocol
/// address.
///
/// In `no_std + alloc`, `BTreeMap` is the canonical container; it is
/// `O(log n)` and deterministic, both of which matter for industrial
/// devices.
#[cfg(feature = "alloc")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThingMapping {
    /// Owning Thing.
    #[cfg_attr(feature = "serde", serde(rename = "thingId"))]
    pub thing_id: ThingId,
    /// Address of every bound property.
    pub bindings: BTreeMap<PropertyPath, ProtocolBinding>,
}

#[cfg(feature = "alloc")]
impl ThingMapping {
    /// Construct an empty mapping.
    pub fn new(thing_id: ThingId) -> Self {
        Self {
            thing_id,
            bindings: BTreeMap::new(),
        }
    }

    /// Bind one path. Returns the previous binding if any.
    pub fn bind(
        &mut self,
        path: PropertyPath,
        binding: ProtocolBinding,
    ) -> Option<ProtocolBinding> {
        self.bindings.insert(path, binding)
    }

    /// Look up a binding.
    pub fn get(&self, path: &PropertyPath) -> Option<&ProtocolBinding> {
        self.bindings.get(path)
    }
}

#[cfg(test)]
#[cfg(feature = "alloc")]
mod tests {
    use super::*;
    use crate::thing::{FeatureId, PropertyId};

    #[test]
    fn bind_and_lookup() {
        let mut m = ThingMapping::new(ThingId::new("acme:pump-7"));
        let p = PropertyPath::new(
            ThingId::new("acme:pump-7"),
            FeatureId::new("temperature"),
            PropertyId::new("value"),
        );
        let b = ProtocolBinding::Modbus {
            unit_id: 1,
            area: RegisterArea::HoldingRegister,
            address: 40001 - 40001, // zero-based on wire
            data_type: DataType::F32,
            byte_order: ByteOrder::BigEndian,
        };
        assert!(m.bind(p.clone(), b.clone()).is_none());
        assert_eq!(m.get(&p), Some(&b));
    }
}