use crate::config::AppMatch;
use crate::platform::cpu_usage::{AppMonitor, TotalsSampler};
use crate::platform::procfs::{self, MAX_KERNEL_FILE_BYTES, read_capped};
use crate::platform::sbc_models::{BOARD_NAMES, builtin_coefficients};
use crate::sensor::{
AppCpuUtilization, CpuUtilization, Platform, PowerSensor, ProcessCpuUtilization,
};
use crate::{Error, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
const MAX_MODEL_BYTES: u64 = 1024 * 1024;
const DEVICE_TREE_MODEL: &str = "/proc/device-tree/model";
const MODEL_JSON_VAR: &str = "SBC_POWER_MODEL_JSON";
const SENSOR: &str = "SBC power model";
#[derive(Debug, Default)]
pub(crate) struct SbcPlatform;
impl Platform for SbcPlatform {
fn cpu(&self) -> Box<dyn PowerSensor> {
Box::new(SbcCpu::new())
}
fn gpu(&self) -> Box<dyn PowerSensor> {
Box::new(SbcGpu)
}
fn cpu_usage(&self) -> Box<dyn CpuUtilization> {
Box::new(procfs::cpu_usage())
}
fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCpuUtilization>> {
Some(procfs::process_tracker())
}
fn app_cpu_usage(
&self,
refresh_interval: Duration,
app_match: AppMatch,
) -> Option<Box<dyn AppCpuUtilization>> {
Some(AppMonitor::boxed(
procfs::process_tracker,
refresh_interval,
app_match,
))
}
}
struct SbcGpu;
impl PowerSensor for SbcGpu {
fn power(&mut self) -> Result<f64> {
Err(Error::sensor(
"SBC GPU",
"single-board computers expose no GPU power interface",
))
}
}
struct SbcCpu {
device: Result<SbcDevice>,
cpu_usage: TotalsSampler,
}
impl SbcCpu {
fn new() -> Self {
let device = SbcDevice::detect();
match &device {
Ok(device) => log::info!("using the SBC power model for {}", device.model),
Err(e) => log::warn!("CPU power will be reported as unavailable: {e}"),
}
Self {
device,
cpu_usage: procfs::cpu_usage(),
}
}
}
impl PowerSensor for SbcCpu {
fn power(&mut self) -> Result<f64> {
match &self.device {
Ok(device) => Ok(device.power_at(self.cpu_usage.cpu_utilization())),
Err(e) => Err(Error::sensor(SENSOR, e.to_string())),
}
}
}
struct SbcDevice {
model: String,
coefficients: Vec<f64>,
}
impl SbcDevice {
fn detect() -> Result<Self> {
let model = detect_model_name()?;
let coefficients = match load_model_file(&model) {
Ok(Some(coefficients)) => coefficients,
Ok(None) => builtin_coefficients(&model)
.ok_or_else(|| {
Error::sensor(SENSOR, format!("no power model for board {model:?}"))
})?
.to_vec(),
Err(e) => {
return Err(e);
}
};
Ok(Self {
model,
coefficients,
})
}
fn power_at(&self, cpu_utilization: f64) -> f64 {
if !cpu_utilization.is_finite() {
return 0.0;
}
let power: f64 = self
.coefficients
.iter()
.enumerate()
.map(|(degree, coefficient)| coefficient * cpu_utilization.powi(degree as i32))
.sum();
if power.is_finite() {
power.max(0.0)
} else {
0.0
}
}
}
fn detect_model_name() -> Result<String> {
let raw = read_capped(Path::new(DEVICE_TREE_MODEL), MAX_KERNEL_FILE_BYTES)
.map_err(|e| Error::sensor(SENSOR, format!("cannot read {DEVICE_TREE_MODEL}: {e}")))?;
let is_64bit = cfg!(target_pointer_width = "64");
BOARD_NAMES
.iter()
.find(|(device_tree_name, _, _)| raw.contains(device_tree_name))
.map(|&(_, name_32, name_64)| if is_64bit { name_64 } else { name_32 }.to_string())
.ok_or_else(|| {
Error::sensor(
SENSOR,
format!("unrecognised board {:?}", raw.trim_end_matches('\0').trim()),
)
})
}
fn load_model_file(model: &str) -> Result<Option<Vec<f64>>> {
let Some(path) = std::env::var_os(MODEL_JSON_VAR).filter(|v| !v.is_empty()) else {
return Ok(None);
};
let path = std::path::PathBuf::from(path);
let bad_file = |detail: String| {
Error::config(format!(
"{MODEL_JSON_VAR} points to {}: {detail}",
path.display()
))
};
let contents = read_capped(&path, MAX_MODEL_BYTES).map_err(|e| bad_file(e.to_string()))?;
let models: HashMap<String, PlatformModel> =
serde_json::from_str(&contents).map_err(|e| bad_file(format!("invalid JSON: {e}")))?;
let platform = models
.get(model)
.ok_or_else(|| bad_file(format!("no entry for board {model:?}")))?;
platform
.polynomial
.coefficients_with_intercept()
.map(Some)
.map_err(bad_file)
}
#[derive(Deserialize)]
struct PlatformModel {
polynomial: PolynomialModel,
}
#[derive(Deserialize)]
struct PolynomialModel {
intercept: String,
coefficients: Vec<String>,
}
impl PolynomialModel {
fn coefficients_with_intercept(&self) -> std::result::Result<Vec<f64>, String> {
let mut parsed = Vec::with_capacity(self.coefficients.len() + 1);
parsed.push(
self.intercept
.trim()
.parse()
.map_err(|e| format!("intercept {:?} is not a number: {e}", self.intercept))?,
);
for coefficient in &self.coefficients {
parsed.push(
coefficient
.trim()
.parse()
.map_err(|e| format!("coefficient {coefficient:?} is not a number: {e}"))?,
);
}
Ok(parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_power_is_the_intercept() {
let device = SbcDevice {
model: "test".into(),
coefficients: vec![2.5, 10.0, -4.0],
};
assert_eq!(device.power_at(0.0), 2.5);
assert_eq!(device.power_at(1.0), 8.5);
}
#[test]
fn power_is_never_negative_or_non_finite() {
let device = SbcDevice {
model: "test".into(),
coefficients: vec![1.0, -100.0],
};
assert_eq!(device.power_at(1.0), 0.0);
assert_eq!(device.power_at(f64::NAN), 0.0);
assert_eq!(device.power_at(f64::INFINITY), 0.0);
}
#[test]
fn model_files_are_parsed_with_the_intercept_first() {
let json = r#"{
"rbp4b1.2": {
"polynomial": {"intercept": "2.5", "coefficients": ["1.5", "-0.5"]}
}
}"#;
let models: HashMap<String, PlatformModel> = serde_json::from_str(json).unwrap();
let coefficients = models["rbp4b1.2"]
.polynomial
.coefficients_with_intercept()
.unwrap();
assert_eq!(coefficients, vec![2.5, 1.5, -0.5]);
}
#[test]
fn unparsable_model_coefficients_are_rejected() {
let json = r#"{
"board": {"polynomial": {"intercept": "2.5", "coefficients": ["oops"]}}
}"#;
let models: HashMap<String, PlatformModel> = serde_json::from_str(json).unwrap();
assert!(
models["board"]
.polynomial
.coefficients_with_intercept()
.is_err()
);
}
}