joularcore 0.1.0

Joular Core is a platform to measure power and energy across all systems, OSes and devices
Documentation
/*
 * Copyright (c) 2025-2026, Adel Noureddine.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the
 * GNU Lesser General Public License v3.0 only (LGPL-3.0-only)
 * which accompanies this distribution, and is available at
 * https://www.gnu.org/licenses/lgpl-3.0.en.html
 *
 * Author : Adel Noureddine
 */

use serde_json::Value;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::process::Command;
use std::str;
use std::sync::OnceLock;

#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;

#[derive(Clone, Copy)]
enum AmdGpuCommand {
    AmdSmi,
    RocmSmi,
}

static AMDGPU_COMMAND: OnceLock<Option<AmdGpuCommand>> = OnceLock::new();

fn amdgpu_smi_output(command_name: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
    let mut cmd = Command::new(command_name);
    cmd.args(args);

    #[cfg(target_os = "windows")]
    {
        // Prevent transient console windows when running from the GUI binary.
        cmd.creation_flags(CREATE_NO_WINDOW);
    }

    cmd.output()
}

/// Returns the total power drawn by AMD GPUs
pub fn get_amdgpu_power() -> f64 {
    let Some(command) = detect_amdgpu_command() else {
        return 0.0;
    };
    let (command_name, args): (&str, &[&str]) = match command {
        AmdGpuCommand::AmdSmi => ("amd-smi", &["metric", "-p", "--json"]),
        AmdGpuCommand::RocmSmi => ("rocm-smi", &["--showpower", "--json"]),
    };

    match amdgpu_smi_output(command_name, args) {
        Ok(output) => {
            if !output.status.success() {
                crate::logging::print_warning(&format!(
                    "AMD GPU command {} exited with non-zero status",
                    command_name
                ));
                return 0.0;
            }
            if let Ok(json_str) = str::from_utf8(&output.stdout) {
                parse_amdgpu_json(json_str)
            } else {
                crate::logging::print_warning("Failed to read AMD GPU output as UTF-8");
                0.0
            }
        }
        Err(e) => {
            crate::logging::print_warning(&format!("Failed to execute AMD GPU command: {}", e));
            0.0
        }
    }
}

/// Checks if AMD GPUs are supported on this system
pub fn is_amdgpu_supported() -> bool {
    detect_amdgpu_command().is_some()
}

fn detect_amdgpu_command() -> Option<AmdGpuCommand> {
    *AMDGPU_COMMAND.get_or_init(|| {
        if is_command_available("amd-smi") {
            Some(AmdGpuCommand::AmdSmi)
        } else if is_command_available("rocm-smi") {
            Some(AmdGpuCommand::RocmSmi)
        } else {
            None
        }
    })
}

fn is_command_available(command: &str) -> bool {
    let mut cmd = Command::new(command);
    cmd.arg("--version");
    #[cfg(target_os = "windows")]
    {
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    cmd.output().is_ok()
}

fn parse_amdgpu_json(json_str: &str) -> f64 {
    let v: Value = match serde_json::from_str(json_str) {
        Ok(v) => v,
        Err(_) => return 0.0,
    };

    match v {
        Value::Object(_) | Value::Array(_) => extract_total_power(&v),
        _ => 0.0,
    }
}

fn extract_total_power(value: &Value) -> f64 {
    match value {
        Value::Object(obj) => {
            if let Some(p) = extract_power_from_object(obj) {
                p
            } else {
                obj.values().map(extract_total_power).sum()
            }
        }
        Value::Array(arr) => arr.iter().map(extract_total_power).sum(),
        _ => 0.0,
    }
}

fn extract_power_from_object(obj: &serde_json::Map<String, Value>) -> Option<f64> {
    // Supported key variants across rocm-smi and amd-smi JSON outputs.
    const POWER_KEYS: &[&str] = &[
        "Average Graphics Package Power (W)",
        "Current Socket Graphics Package Power (W)",
        "current_socket_graphics_package_power",
        "current_graphics_package_power",
        "average_socket_power",
        "socket_power",
        "current_socket_power",
        "Power (W)",
        "Power",
        "average_power",
    ];

    for &key in POWER_KEYS {
        if let Some(power_val) = obj.get(key)
            && let Some(p) = parse_power_value(power_val)
        {
            return Some(p);
        }
    }

    // Common amd-smi nesting: "power": {"socket_power": {"value": .., "unit": "W"}}
    if let Some(power_obj) = obj.get("power").and_then(Value::as_object) {
        return extract_power_from_object(power_obj);
    }

    None
}

fn parse_power_value(value: &Value) -> Option<f64> {
    if let Some(p) = value.as_f64() {
        return Some(p);
    }
    if let Some(s) = value.as_str() {
        return parse_numeric_string(s);
    }
    if let Some(obj) = value.as_object()
        && let Some(v) = obj.get("value")
    {
        return parse_power_value(v);
    }
    None
}

fn parse_numeric_string(s: &str) -> Option<f64> {
    s.trim().parse::<f64>().ok().or_else(|| {
        let first_token = s.split_whitespace().next()?;
        first_token.parse::<f64>().ok()
    })
}