rns-embedded-mininode 0.7.1

Minimal embedded Reticulum node helpers for constrained LXMF deployments.
Documentation
use alloc::{
    collections::BTreeMap,
    string::{String, ToString},
    vec,
    vec::Vec,
};

use rmpv::Value;

use crate::error::MiniNodeError;

#[derive(Debug, Clone, PartialEq)]
pub enum TelemetryValue {
    Integer(i64),
    Unsigned(u64),
    Float(f64),
    Bool(bool),
    Text(String),
}

impl TelemetryValue {
    pub fn to_rmpv(&self) -> Value {
        match self {
            Self::Integer(value) => Value::Integer((*value).into()),
            Self::Unsigned(value) => Value::Integer((*value).into()),
            Self::Float(value) => Value::F64(*value),
            Self::Bool(value) => Value::Boolean(*value),
            Self::Text(value) => Value::String(value.as_str().into()),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct TelemetryPoint {
    pub ts_ms: u64,
    pub key: String,
    pub value: TelemetryValue,
    pub unit: Option<String>,
    pub tags: BTreeMap<String, String>,
}

impl TelemetryPoint {
    pub fn to_rmpv(&self) -> Value {
        let mut entries = vec![
            (Value::String("ts_ms".into()), Value::Integer(self.ts_ms.into())),
            (Value::String("key".into()), Value::String(self.key.as_str().into())),
            (Value::String("value".into()), self.value.to_rmpv()),
        ];

        if let Some(unit) = &self.unit {
            entries.push((Value::String("unit".into()), Value::String(unit.as_str().into())));
        }

        if !self.tags.is_empty() {
            let tags = self
                .tags
                .iter()
                .map(|(key, value)| {
                    (Value::String(key.as_str().into()), Value::String(value.as_str().into()))
                })
                .collect();
            entries.push((Value::String("tags".into()), Value::Map(tags)));
        }

        Value::Map(entries)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TelemetryQuery {
    pub from_ts_ms: Option<u64>,
    pub to_ts_ms: Option<u64>,
    pub key_prefix: Option<String>,
    pub limit: Option<usize>,
}

impl TelemetryQuery {
    pub fn matches(&self, point: &TelemetryPoint) -> bool {
        if let Some(from) = self.from_ts_ms {
            if point.ts_ms < from {
                return false;
            }
        }
        if let Some(to) = self.to_ts_ms {
            if point.ts_ms > to {
                return false;
            }
        }
        if let Some(prefix) = &self.key_prefix {
            if !point.key.starts_with(prefix) {
                return false;
            }
        }
        true
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PositionFix {
    pub ts_ms: u64,
    pub lat: f64,
    pub lon: f64,
    pub alt_m: Option<f64>,
    pub speed_mps: Option<f64>,
    pub heading_deg: Option<f64>,
    pub accuracy_m: Option<f64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BatteryStatus {
    pub ts_ms: u64,
    pub pct: u8,
    pub millivolts: u16,
    pub charging: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkStats {
    pub ts_ms: u64,
    pub rssi_dbm: Option<i16>,
    pub snr_db: Option<f32>,
    pub airtime_ms: u32,
    pub rx_ok: u32,
    pub rx_drop: u32,
    pub tx_ok: u32,
    pub tx_drop: u32,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DeviceHealth {
    pub ts_ms: u64,
    pub uptime_ms: u64,
    pub free_bytes: Option<u32>,
    pub temperature_c: Option<f32>,
}

#[derive(Debug, Clone, PartialEq)]
pub enum TelemetrySample {
    PositionFix(PositionFix),
    BatteryStatus(BatteryStatus),
    LinkStats(LinkStats),
    DeviceHealth(DeviceHealth),
    Point(TelemetryPoint),
}

impl TelemetrySample {
    pub fn into_points(self, source_hash: [u8; 16]) -> (Vec<TelemetryPoint>, Option<PositionFix>) {
        let mut tags = BTreeMap::new();
        tags.insert("source".to_string(), hex::encode(source_hash));

        match self {
            Self::PositionFix(fix) => {
                let mut points = vec![
                    TelemetryPoint {
                        ts_ms: fix.ts_ms,
                        key: "position.lat".to_string(),
                        value: TelemetryValue::Float(fix.lat),
                        unit: Some("deg".to_string()),
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: fix.ts_ms,
                        key: "position.lon".to_string(),
                        value: TelemetryValue::Float(fix.lon),
                        unit: Some("deg".to_string()),
                        tags: tags.clone(),
                    },
                ];

                if let Some(alt_m) = fix.alt_m {
                    points.push(TelemetryPoint {
                        ts_ms: fix.ts_ms,
                        key: "position.alt_m".to_string(),
                        value: TelemetryValue::Float(alt_m),
                        unit: Some("m".to_string()),
                        tags: tags.clone(),
                    });
                }
                if let Some(speed_mps) = fix.speed_mps {
                    points.push(TelemetryPoint {
                        ts_ms: fix.ts_ms,
                        key: "position.speed_mps".to_string(),
                        value: TelemetryValue::Float(speed_mps),
                        unit: Some("m/s".to_string()),
                        tags: tags.clone(),
                    });
                }
                if let Some(heading_deg) = fix.heading_deg {
                    points.push(TelemetryPoint {
                        ts_ms: fix.ts_ms,
                        key: "position.heading_deg".to_string(),
                        value: TelemetryValue::Float(heading_deg),
                        unit: Some("deg".to_string()),
                        tags: tags.clone(),
                    });
                }
                if let Some(accuracy_m) = fix.accuracy_m {
                    points.push(TelemetryPoint {
                        ts_ms: fix.ts_ms,
                        key: "position.accuracy_m".to_string(),
                        value: TelemetryValue::Float(accuracy_m),
                        unit: Some("m".to_string()),
                        tags,
                    });
                }
                (points, Some(fix))
            }
            Self::BatteryStatus(status) => (
                vec![
                    TelemetryPoint {
                        ts_ms: status.ts_ms,
                        key: "battery.pct".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(status.pct)),
                        unit: Some("%".to_string()),
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: status.ts_ms,
                        key: "battery.mv".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(status.millivolts)),
                        unit: Some("mV".to_string()),
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: status.ts_ms,
                        key: "battery.charging".to_string(),
                        value: TelemetryValue::Bool(status.charging),
                        unit: None,
                        tags,
                    },
                ],
                None,
            ),
            Self::LinkStats(stats) => {
                let mut points = vec![
                    TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.airtime_ms".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(stats.airtime_ms)),
                        unit: Some("ms".to_string()),
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.rx_ok".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(stats.rx_ok)),
                        unit: None,
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.rx_drop".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(stats.rx_drop)),
                        unit: None,
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.tx_ok".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(stats.tx_ok)),
                        unit: None,
                        tags: tags.clone(),
                    },
                    TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.tx_drop".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(stats.tx_drop)),
                        unit: None,
                        tags: tags.clone(),
                    },
                ];

                if let Some(rssi) = stats.rssi_dbm {
                    points.push(TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.rssi_dbm".to_string(),
                        value: TelemetryValue::Integer(i64::from(rssi)),
                        unit: Some("dBm".to_string()),
                        tags: tags.clone(),
                    });
                }
                if let Some(snr) = stats.snr_db {
                    points.push(TelemetryPoint {
                        ts_ms: stats.ts_ms,
                        key: "link.snr_db".to_string(),
                        value: TelemetryValue::Float(f64::from(snr)),
                        unit: Some("dB".to_string()),
                        tags,
                    });
                }

                (points, None)
            }
            Self::DeviceHealth(health) => {
                let mut points = vec![TelemetryPoint {
                    ts_ms: health.ts_ms,
                    key: "device.uptime_ms".to_string(),
                    value: TelemetryValue::Unsigned(health.uptime_ms),
                    unit: Some("ms".to_string()),
                    tags: tags.clone(),
                }];

                if let Some(free_bytes) = health.free_bytes {
                    points.push(TelemetryPoint {
                        ts_ms: health.ts_ms,
                        key: "device.free_bytes".to_string(),
                        value: TelemetryValue::Unsigned(u64::from(free_bytes)),
                        unit: Some("bytes".to_string()),
                        tags: tags.clone(),
                    });
                }
                if let Some(temperature_c) = health.temperature_c {
                    points.push(TelemetryPoint {
                        ts_ms: health.ts_ms,
                        key: "device.temperature_c".to_string(),
                        value: TelemetryValue::Float(f64::from(temperature_c)),
                        unit: Some("C".to_string()),
                        tags,
                    });
                }

                (points, None)
            }
            Self::Point(mut point) => {
                point.tags.entry("source".to_string()).or_insert_with(|| hex::encode(source_hash));
                (vec![point], None)
            }
        }
    }
}

pub fn encode_recent_fields(
    points: &[TelemetryPoint],
    latest_position: Option<&PositionFix>,
) -> Result<Value, MiniNodeError> {
    let mut entries = Vec::new();

    if let Some(position) = latest_position {
        entries.push((Value::Integer(2.into()), Value::Binary(encode_position_payload(position)?)));
    }

    if !points.is_empty() {
        let telemetry_values = points.iter().map(TelemetryPoint::to_rmpv).collect::<Vec<_>>();
        let encoded = rmp_serde::to_vec(&Value::Array(telemetry_values))
            .map_err(|_| MiniNodeError::StorageError)?;
        entries.push((Value::Integer(3.into()), Value::Binary(encoded)));
    }

    Ok(Value::Map(entries))
}

fn encode_position_payload(position: &PositionFix) -> Result<Vec<u8>, MiniNodeError> {
    let lat = ((position.lat * 1_000_000.0).round()) as i32;
    let lon = ((position.lon * 1_000_000.0).round()) as i32;
    let alt = ((position.alt_m.unwrap_or_default() * 100.0).round()) as i32;
    let speed = ((position.speed_mps.unwrap_or_default() * 100.0).round()) as u32;
    let heading = ((position.heading_deg.unwrap_or_default() * 100.0).round()) as i32;
    let accuracy = ((position.accuracy_m.unwrap_or_default() * 100.0).round()) as u16;

    let position_array = Value::Array(vec![
        Value::Binary(lat.to_be_bytes().to_vec()),
        Value::Binary(lon.to_be_bytes().to_vec()),
        Value::Binary(alt.to_be_bytes().to_vec()),
        Value::Binary(speed.to_be_bytes().to_vec()),
        Value::Binary(heading.to_be_bytes().to_vec()),
        Value::Binary(accuracy.to_be_bytes().to_vec()),
        Value::Integer(position.ts_ms.into()),
    ]);

    let payload = Value::Map(vec![(Value::Integer(0x02.into()), position_array)]);
    rmp_serde::to_vec(&payload).map_err(|_| MiniNodeError::StorageError)
}