use linsight_core::{Category, HardwareDevice, HardwareDeviceKey, SensorId, SensorKind, Unit};
use stabby::option::Option as SOption;
use stabby::string::String as SString;
use stabby::vec::Vec as SVec;
use crate::mirror::{RCategory, RHardwareDevice, RSensorId, RSensorKind, RUnit};
use crate::plugin::PluginError;
pub const MIN_RATE_HZ: f32 = 0.1;
pub const MAX_RATE_HZ: f32 = 20.0;
#[derive(Clone, Debug, PartialEq)]
pub struct PluginManifest {
pub plugin_id: String,
pub display_name: String,
pub version: String,
pub sensors: Vec<SensorDescriptor>,
pub devices: Vec<HardwareDevice>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PluginMetadata {
pub plugin_id: String,
pub display_name: String,
pub version: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SensorDescriptor {
pub id: SensorId,
pub display_name: String,
pub unit: Unit,
pub kind: SensorKind,
pub category: Category,
pub native_rate_hz: f32,
pub min: Option<f64>,
pub max: Option<f64>,
pub device_id: Option<String>,
pub device_key: Option<HardwareDeviceKey>,
pub tags: Vec<String>,
}
impl SensorDescriptor {
pub fn clamped_rate_hz(&self) -> f32 {
self.native_rate_hz.clamp(MIN_RATE_HZ, MAX_RATE_HZ)
}
}
#[stabby::stabby]
#[derive(Clone, Debug)]
pub struct RSensorDescriptor {
pub id: RSensorId,
pub display_name: SString,
pub unit: RUnit,
pub kind: RSensorKind,
pub category: RCategory,
pub native_rate_hz: f32,
pub min: SOption<f64>,
pub max: SOption<f64>,
pub device_id: SOption<SString>,
pub device_key: SOption<SString>,
pub tags: SVec<SString>,
}
#[stabby::stabby]
#[derive(Clone, Debug)]
pub struct RPluginManifest {
pub plugin_id: SString,
pub display_name: SString,
pub version: SString,
pub sensors: SVec<RSensorDescriptor>,
pub devices: SVec<RHardwareDevice>,
}
#[stabby::stabby]
#[derive(Clone, Debug)]
pub struct RPluginMetadata {
pub plugin_id: SString,
pub display_name: SString,
pub version: SString,
}
impl From<SensorDescriptor> for RSensorDescriptor {
fn from(d: SensorDescriptor) -> Self {
Self {
id: d.id.into(),
display_name: d.display_name.as_str().into(),
unit: d.unit.into(),
kind: d.kind.into(),
category: d.category.into(),
native_rate_hz: d.native_rate_hz,
min: d.min.into(),
max: d.max.into(),
device_id: d.device_id.map(|s| SString::from(s.as_str())).into(),
device_key: d.device_key.map(|k| SString::from(k.as_str())).into(),
tags: d.tags.iter().map(|t| t.as_str().into()).collect::<SVec<_>>(),
}
}
}
impl From<RSensorDescriptor> for SensorDescriptor {
fn from(r: RSensorDescriptor) -> Self {
let device_id: Option<SString> = r.device_id.into();
let device_key: Option<HardwareDeviceKey> = Option::from(r.device_key).map(|s: SString| {
HardwareDeviceKey::try_new(s.as_str().to_owned()).expect("validated by host_init")
});
Self {
id: r.id.into(),
display_name: r.display_name.as_str().to_owned(),
unit: r.unit.into(),
kind: r.kind.into(),
category: r.category.into(),
native_rate_hz: r.native_rate_hz,
min: r.min.into(),
max: r.max.into(),
device_id: device_id.map(|s| s.as_str().to_owned()),
device_key,
tags: crate::mirror::svec_into_std(r.tags),
}
}
}
impl From<PluginManifest> for RPluginManifest {
fn from(m: PluginManifest) -> Self {
let mut sensors = SVec::with_capacity(m.sensors.len());
for s in m.sensors {
sensors.push(s.into());
}
let mut devices = SVec::with_capacity(m.devices.len());
for d in m.devices {
devices.push(d.into());
}
Self {
plugin_id: m.plugin_id.as_str().into(),
display_name: m.display_name.as_str().into(),
version: m.version.as_str().into(),
sensors,
devices,
}
}
}
impl From<RPluginManifest> for PluginManifest {
fn from(r: RPluginManifest) -> Self {
let sensors: Vec<SensorDescriptor> = crate::mirror::svec_into_std(r.sensors);
let devices: Vec<HardwareDevice> = crate::mirror::svec_into_std(r.devices);
Self {
plugin_id: r.plugin_id.as_str().to_owned(),
display_name: r.display_name.as_str().to_owned(),
version: r.version.as_str().to_owned(),
sensors,
devices,
}
}
}
impl From<PluginMetadata> for RPluginMetadata {
fn from(m: PluginMetadata) -> Self {
Self {
plugin_id: m.plugin_id.as_str().into(),
display_name: m.display_name.as_str().into(),
version: m.version.as_str().into(),
}
}
}
impl From<RPluginMetadata> for PluginMetadata {
fn from(r: RPluginMetadata) -> Self {
Self {
plugin_id: r.plugin_id.as_str().to_owned(),
display_name: r.display_name.as_str().to_owned(),
version: r.version.as_str().to_owned(),
}
}
}
pub(crate) fn validate_manifest(m: &RPluginManifest) -> Result<(), PluginError> {
use std::collections::HashSet;
let mut keys = HashSet::new();
for dev in m.devices.iter() {
let key_str = dev.key.as_str();
if let Err(e) = HardwareDeviceKey::try_new(key_str.to_owned()) {
return Err(PluginError::Manifest(format!("invalid device key {:?}: {}", key_str, e)));
}
if !keys.insert(key_str.to_owned()) {
return Err(PluginError::Manifest(format!(
"duplicate device key in manifest: {:?}",
key_str
)));
}
}
for s in m.sensors.iter() {
let opt: Option<&SString> = s.device_key.as_ref();
if let Some(key_s) = opt {
let k = key_s.as_str();
if !keys.contains(k) {
return Err(PluginError::Manifest(format!(
"sensor {} references device_key {:?} not in manifest.devices",
s.id.value.as_str(),
k
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sensor_descriptor_clamps_native_rate() {
let d = SensorDescriptor {
id: SensorId::new("foo"),
display_name: "Foo".into(),
unit: Unit::Percent,
kind: SensorKind::Scalar,
category: Category::Cpu,
native_rate_hz: 99.0,
min: None,
max: None,
device_id: None,
device_key: None,
tags: vec![],
};
assert_eq!(d.clamped_rate_hz(), 20.0);
}
#[test]
fn sensor_descriptor_clamps_low_rate() {
let d = SensorDescriptor {
id: SensorId::new("foo"),
display_name: "Foo".into(),
unit: Unit::Percent,
kind: SensorKind::Scalar,
category: Category::Cpu,
native_rate_hz: 0.001,
min: None,
max: None,
device_id: None,
device_key: None,
tags: vec![],
};
assert_eq!(d.clamped_rate_hz(), 0.1);
}
#[test]
fn descriptor_round_trips_through_r_mirror() {
let d = SensorDescriptor {
id: SensorId::new("net.eth0.rx_bytes"),
display_name: "rx".into(),
unit: Unit::Custom("Mb/s".into()),
kind: SensorKind::Counter,
category: Category::Network,
native_rate_hz: 2.0,
min: Some(0.0),
max: None,
device_id: Some("eth0".into()),
device_key: None,
tags: vec!["network".into()],
};
let r: RSensorDescriptor = d.clone().into();
let back: SensorDescriptor = r.into();
assert_eq!(d, back);
}
#[test]
fn manifest_round_trips_through_r_mirror() {
let m = PluginManifest {
plugin_id: "io.example".into(),
display_name: "Example".into(),
version: "1.2.3".into(),
sensors: vec![SensorDescriptor {
id: SensorId::new("ex.s"),
display_name: "s".into(),
unit: Unit::Percent,
kind: SensorKind::Scalar,
category: Category::Custom,
native_rate_hz: 1.0,
min: Some(0.0),
max: Some(100.0),
device_id: None,
device_key: None,
tags: vec![],
}],
devices: vec![],
};
let r: RPluginManifest = m.clone().into();
let back: PluginManifest = r.into();
assert_eq!(m, back);
}
#[test]
fn manifest_with_many_sensors_preserves_order() {
let sensors: Vec<SensorDescriptor> = (0..10)
.map(|i| SensorDescriptor {
id: SensorId::new(format!("sensor.{i}")),
display_name: format!("Sensor {i}"),
unit: Unit::Count,
kind: SensorKind::Scalar,
category: Category::Custom,
native_rate_hz: 1.0,
min: None,
max: None,
device_id: None,
device_key: None,
tags: vec![],
})
.collect();
let m = PluginManifest {
plugin_id: "io.example".into(),
display_name: "Example".into(),
version: "1.2.3".into(),
sensors: sensors.clone(),
devices: vec![],
};
let r: RPluginManifest = m.into();
let back: PluginManifest = r.into();
let back_ids: Vec<String> = back.sensors.iter().map(|s| s.id.to_string()).collect();
let expect_ids: Vec<String> = sensors.iter().map(|s| s.id.to_string()).collect();
assert_eq!(back_ids, expect_ids, "sensor order must be preserved through FFI mirror");
}
use crate::mirror::{RHardwareCategoryKind, RHardwareDevice};
fn make_minimal_manifest() -> RPluginManifest {
RPluginManifest {
plugin_id: "test.minimal".into(),
display_name: "Minimal".into(),
version: "0.0.1".into(),
sensors: SVec::new(),
devices: SVec::new(),
}
}
fn r_dev(key: &str, category: RHardwareCategoryKind, model: &str) -> RHardwareDevice {
RHardwareDevice {
key: key.into(),
category_kind: category,
model: model.into(),
vendor: SOption::None(),
location: SOption::None(),
plugin_device_id: "dev0".into(),
}
}
fn make_minimal_manifest_with_one_device() -> RPluginManifest {
let mut m = make_minimal_manifest();
let mut devs = SVec::new();
devs.push(r_dev("pci:0000:06:00.0", RHardwareCategoryKind::Gpu, "Arc B-series"));
m.devices = devs;
m
}
#[test]
fn host_init_rejects_invalid_device_key() {
let mut m = make_minimal_manifest();
let mut devs = SVec::new();
devs.push(r_dev("BAD_KEY", RHardwareCategoryKind::Gpu, "bogus"));
m.devices = devs;
let err = validate_manifest(&m).unwrap_err();
match err {
PluginError::Manifest(msg) => {
assert!(msg.contains("BAD_KEY"), "expected error to name the bad key; got: {msg}",);
}
other => panic!("expected PluginError::Manifest, got {other:?}"),
}
}
#[test]
fn host_init_rejects_sensor_pointing_at_absent_device() {
let mut m = make_minimal_manifest_with_one_device();
let mut sensors = SVec::new();
sensors.push(RSensorDescriptor {
id: RSensorId { value: "test.dangling".into() },
display_name: "Dangling".into(),
unit: crate::mirror::RUnit {
kind: crate::mirror::RUnitKind::Count,
custom: SOption::None(),
},
kind: RSensorKind::Scalar,
category: RCategory::Custom,
native_rate_hz: 1.0,
min: SOption::None(),
max: SOption::None(),
device_id: SOption::None(),
device_key: SOption::Some(SString::from("pci:0000:99:00.0")),
tags: SVec::new(),
});
m.sensors = sensors;
let err = validate_manifest(&m).unwrap_err();
match err {
PluginError::Manifest(msg) => {
assert!(
msg.contains("test.dangling") && msg.contains("pci:0000:99:00.0"),
"expected error to name the sensor and the dangling key; got: {msg}",
);
}
other => panic!("expected PluginError::Manifest, got {other:?}"),
}
}
#[test]
fn host_init_rejects_duplicate_device_keys_within_manifest() {
let mut m = make_minimal_manifest();
let mut devs = SVec::new();
devs.push(r_dev("pci:0000:06:00.0", RHardwareCategoryKind::Gpu, "first"));
devs.push(r_dev("pci:0000:06:00.0", RHardwareCategoryKind::Gpu, "duplicate"));
m.devices = devs;
let err = validate_manifest(&m).unwrap_err();
match err {
PluginError::Manifest(msg) => {
assert!(
msg.contains("duplicate") && msg.contains("pci:0000:06:00.0"),
"expected error to flag duplicate and the key; got: {msg}",
);
}
other => panic!("expected PluginError::Manifest, got {other:?}"),
}
}
#[test]
fn host_init_accepts_valid_v4_manifest_with_devices_and_sensor_reference() {
let mut m = make_minimal_manifest_with_one_device();
let mut sensors = SVec::new();
sensors.push(RSensorDescriptor {
id: RSensorId { value: "test.linked".into() },
display_name: "Linked".into(),
unit: crate::mirror::RUnit {
kind: crate::mirror::RUnitKind::Percent,
custom: SOption::None(),
},
kind: RSensorKind::Scalar,
category: RCategory::Gpu,
native_rate_hz: 1.0,
min: SOption::None(),
max: SOption::None(),
device_id: SOption::None(),
device_key: SOption::Some(SString::from("pci:0000:06:00.0")),
tags: SVec::new(),
});
m.sensors = sensors;
assert!(validate_manifest(&m).is_ok());
}
}