rover_nexus_core 0.2.0

Wire-format message types (Cap'n Proto + JSON) for communication between robotic vehicles and a robot orchestration server: Rover Nexus
Documentation
// Copyright 2026 Rottinghaus Dynamics
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::core_model::SystemHealth;
use std::fs;

impl SystemHealth {
    pub fn collect(device_id: String) -> Self {
        Self {
            unix_time_ms: get_unix_time_ms(),
            device_id,
            cpu_pct: get_cpu_pct(),
            mem_pct: get_mem_pct(),
            disk_pct: get_disk_pct("/"),
            cpu_temp: get_cpu_temp(),
            signal_strength: get_wifi_rssi(),
            signal_quality: get_wifi_quality(),
        }
    }
}

fn get_unix_time_ms() -> i64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

// CPU % requires two samples since it's based on deltas
// For a single snapshot, you can read /proc/loadavg instead
fn get_cpu_pct() -> f32 {
    // Quick approach: use 1-min load average scaled by CPU count
    let loadavg = fs::read_to_string("/proc/loadavg").unwrap_or_default();
    let load: f32 = loadavg
        .split_whitespace()
        .next()
        .and_then(|s| s.parse().ok())
        .unwrap_or(0.0);

    let cpus = std::thread::available_parallelism()
        .map(|n| n.get() as f32)
        .unwrap_or(1.0);

    (load / cpus * 100.0).min(100.0)
}

fn get_mem_pct() -> f32 {
    let content = fs::read_to_string("/proc/meminfo").unwrap_or_default();
    let mut total = 0u64;
    let mut available = 0u64;

    for line in content.lines() {
        let mut parts = line.split_whitespace();
        match parts.next() {
            Some("MemTotal:") => total = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0),
            Some("MemAvailable:") => {
                available = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0)
            }
            _ => {}
        }
    }

    if total > 0 {
        (1.0 - available as f32 / total as f32) * 100.0
    } else {
        0.0
    }
}

fn get_disk_pct(path: &str) -> f32 {
    // Use libc statvfs - this is the most reliable cross-linux method
    use std::ffi::CString;
    use std::mem::MaybeUninit;

    let path = match CString::new(path) {
        Ok(p) => p,
        Err(_) => return 0.0, // path contained an interior NUL; treat as unavailable
    };
    let mut stat = MaybeUninit::<libc::statvfs>::uninit();

    unsafe {
        if libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) == 0 {
            let stat = stat.assume_init();
            let total = stat.f_blocks as f64 * stat.f_frsize as f64;
            let free = stat.f_bfree as f64 * stat.f_frsize as f64;
            if total > 0.0 {
                return ((1.0 - free / total) * 100.0) as f32;
            }
        }
    }
    0.0
}

fn get_cpu_temp() -> f32 {
    // Try hwmon thermal zones (most common)
    let paths = [
        "/sys/class/thermal/thermal_zone0/temp",
        "/sys/class/hwmon/hwmon0/temp1_input",
        "/sys/class/hwmon/hwmon1/temp1_input",
        "/sys/class/hwmon/hwmon2/temp1_input",
    ];

    for path in paths {
        if let Ok(content) = fs::read_to_string(path) {
            if let Ok(millidegrees) = content.trim().parse::<i32>() {
                return millidegrees as f32 / 1000.0;
            }
        }
    }

    // Fallback: scan all hwmon devices for CPU temp
    if let Ok(entries) = fs::read_dir("/sys/class/hwmon") {
        for entry in entries.flatten() {
            let name_path = entry.path().join("name");
            if let Ok(name) = fs::read_to_string(&name_path) {
                let name = name.trim();
                // coretemp = Intel, k10temp = AMD, cpu_thermal = ARM
                if name == "coretemp" || name == "k10temp" || name == "cpu_thermal" {
                    let temp_path = entry.path().join("temp1_input");
                    if let Ok(temp) = fs::read_to_string(&temp_path) {
                        if let Ok(millidegrees) = temp.trim().parse::<i32>() {
                            return millidegrees as f32 / 1000.0;
                        }
                    }
                }
            }
        }
    }

    0.0 // or f32::NAN to indicate unavailable
}

fn get_wifi_rssi() -> f32 {
    // /proc/net/wireless has signal level in dBm (usually negative)
    let content = fs::read_to_string("/proc/net/wireless").unwrap_or_default();

    // Skip header lines, parse first interface
    for line in content.lines().skip(2) {
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() >= 4 {
            // Column 3 is signal level, may have trailing '.'
            let signal = parts[3].trim_end_matches('.');
            if let Ok(rssi) = signal.parse::<f32>() {
                return rssi; // dBm, typically -30 to -90
            }
        }
    }

    0.0
}

fn get_wifi_quality() -> f32 {
    // Link quality from /proc/net/wireless (column 2)
    let content = fs::read_to_string("/proc/net/wireless").unwrap_or_default();

    for line in content.lines().skip(2) {
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() >= 3 {
            let quality = parts[2].trim_end_matches('.');
            if let Ok(q) = quality.parse::<f32>() {
                // Quality is typically 0-70, normalize to 0-100
                return (q / 70.0 * 100.0).min(100.0);
            }
        }
    }

    0.0
}