corrige temperature

This commit is contained in:
2026-01-10 20:24:11 +01:00
parent ff3fc65eef
commit 91bfcff1fe
2486 changed files with 31797 additions and 544 deletions
+495 -3
View File
@@ -2,6 +2,7 @@
// Il expose des structures de donnees simples pour le reste du code.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
@@ -41,6 +42,405 @@ fn default_sw_version() -> String {
"2.0.0".to_string()
}
fn default_telemetry_metrics() -> HashMap<String, TelemetryMetric> {
let mut metrics = HashMap::new();
metrics.insert(
"cpu_usage".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("CPU Usage".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:chip".to_string()),
},
);
metrics.insert(
"pilot_v2_cpu_usage".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("Pilot V2 CPU Usage".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:application".to_string()),
},
);
metrics.insert(
"pilot_v2_mem_used_mb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("Pilot V2 Memory Used".to_string()),
unique_id: None,
unit: Some("MB".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:application".to_string()),
},
);
metrics.insert(
"cpu_temp_c".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("CPU Temp".to_string()),
unique_id: None,
unit: Some("°C".to_string()),
device_class: Some("temperature".to_string()),
state_class: Some("measurement".to_string()),
icon: Some("mdi:thermometer".to_string()),
},
);
metrics.insert(
"ssd_temp_c".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 30,
name: Some("SSD Temp".to_string()),
unique_id: None,
unit: Some("°C".to_string()),
device_class: Some("temperature".to_string()),
state_class: Some("measurement".to_string()),
icon: Some("mdi:thermometer".to_string()),
},
);
metrics.insert(
"gpu_usage".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU Usage".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:gpu".to_string()),
},
);
metrics.insert(
"gpu0_usage".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU0 Usage".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:gpu".to_string()),
},
);
metrics.insert(
"gpu1_usage".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU1 Usage".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:gpu".to_string()),
},
);
metrics.insert(
"gpu0_temp_c".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU0 Temp".to_string()),
unique_id: None,
unit: Some("°C".to_string()),
device_class: Some("temperature".to_string()),
state_class: Some("measurement".to_string()),
icon: Some("mdi:thermometer".to_string()),
},
);
metrics.insert(
"gpu1_temp_c".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU1 Temp".to_string()),
unique_id: None,
unit: Some("°C".to_string()),
device_class: Some("temperature".to_string()),
state_class: Some("measurement".to_string()),
icon: Some("mdi:thermometer".to_string()),
},
);
metrics.insert(
"gpu0_mem_used_gb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU0 Memory Used".to_string()),
unique_id: None,
unit: Some("GB".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:memory".to_string()),
},
);
metrics.insert(
"gpu1_mem_used_gb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU1 Memory Used".to_string()),
unique_id: None,
unit: Some("GB".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:memory".to_string()),
},
);
metrics.insert(
"amd_gpu_usage".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("AMD GPU Usage".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:gpu".to_string()),
},
);
metrics.insert(
"amd_gpu_temp_c".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("AMD GPU Temp".to_string()),
unique_id: None,
unit: Some("°C".to_string()),
device_class: Some("temperature".to_string()),
state_class: Some("measurement".to_string()),
icon: Some("mdi:thermometer".to_string()),
},
);
metrics.insert(
"amd_gpu_mem_used_gb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("AMD GPU Memory Used".to_string()),
unique_id: None,
unit: Some("GB".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:memory".to_string()),
},
);
metrics.insert(
"memory_used_gb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("Memory Used".to_string()),
unique_id: None,
unit: Some("GB".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:memory".to_string()),
},
);
metrics.insert(
"memory_total_gb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 60,
name: Some("Memory Total".to_string()),
unique_id: None,
unit: Some("GB".to_string()),
device_class: None,
state_class: None,
icon: Some("mdi:memory".to_string()),
},
);
metrics.insert(
"disk_free_gb".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 60,
name: Some("Disk Free".to_string()),
unique_id: None,
unit: Some("GB".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:harddisk".to_string()),
},
);
metrics.insert(
"fan_cpu_rpm".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("CPU Fan".to_string()),
unique_id: None,
unit: Some("RPM".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:fan".to_string()),
},
);
metrics.insert(
"fan_gpu_rpm".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 5,
name: Some("GPU Fan".to_string()),
unique_id: None,
unit: Some("RPM".to_string()),
device_class: None,
state_class: Some("measurement".to_string()),
icon: Some("mdi:fan".to_string()),
},
);
metrics.insert(
"ip_address".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 60,
name: Some("IP Address".to_string()),
unique_id: None,
unit: None,
device_class: None,
state_class: None,
icon: Some("mdi:ip".to_string()),
},
);
metrics.insert(
"battery_level".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 30,
name: Some("Battery Level".to_string()),
unique_id: None,
unit: Some("%".to_string()),
device_class: Some("battery".to_string()),
state_class: Some("measurement".to_string()),
icon: Some("mdi:battery".to_string()),
},
);
metrics.insert(
"battery_state".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 30,
name: Some("Battery State".to_string()),
unique_id: None,
unit: None,
device_class: None,
state_class: None,
icon: Some("mdi:battery-charging".to_string()),
},
);
metrics.insert(
"power_state".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 30,
name: Some("Power State".to_string()),
unique_id: None,
unit: None,
device_class: None,
state_class: None,
icon: Some("mdi:power".to_string()),
},
);
metrics.insert(
"kernel".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 3600,
name: Some("Kernel".to_string()),
unique_id: None,
unit: None,
device_class: None,
state_class: None,
icon: Some("mdi:linux".to_string()),
},
);
metrics.insert(
"os_version".to_string(),
TelemetryMetric {
enabled: true,
discovery_enabled: true,
interval_s: 3600,
name: Some("OS Version".to_string()),
unique_id: None,
unit: None,
device_class: None,
state_class: None,
icon: Some("mdi:desktop-classic".to_string()),
},
);
metrics
}
fn default_metric_enabled() -> bool {
true
}
fn default_metric_discovery_enabled() -> bool {
true
}
fn default_metric_interval_s() -> u64 {
10
}
fn default_mqtt_reconnect_attempts() -> u32 {
3
}
fn default_mqtt_reconnect_retry_delay_s() -> u64 {
1
}
fn default_mqtt_reconnect_short_wait_s() -> u64 {
60
}
fn default_mqtt_reconnect_long_wait_s() -> u64 {
3600
}
#[derive(Debug, Clone, Deserialize)]
pub struct Mqtt {
pub host: String,
@@ -53,6 +453,31 @@ pub struct Mqtt {
pub keepalive_s: u64,
pub qos: u8,
pub retain_states: bool,
#[serde(default)]
pub reconnect: MqttReconnect,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MqttReconnect {
#[serde(default = "default_mqtt_reconnect_attempts")]
pub attempts: u32,
#[serde(default = "default_mqtt_reconnect_retry_delay_s")]
pub retry_delay_s: u64,
#[serde(default = "default_mqtt_reconnect_short_wait_s")]
pub short_wait_s: u64,
#[serde(default = "default_mqtt_reconnect_long_wait_s")]
pub long_wait_s: u64,
}
impl Default for MqttReconnect {
fn default() -> Self {
Self {
attempts: default_mqtt_reconnect_attempts(),
retry_delay_s: default_mqtt_reconnect_retry_delay_s(),
short_wait_s: default_mqtt_reconnect_short_wait_s(),
long_wait_s: default_mqtt_reconnect_long_wait_s(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
@@ -64,7 +489,30 @@ pub struct Features {
#[derive(Debug, Clone, Deserialize)]
pub struct Telemetry {
pub enabled: bool,
#[serde(default = "default_telemetry_metrics")]
pub metrics: HashMap<String, TelemetryMetric>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TelemetryMetric {
#[serde(default = "default_metric_enabled")]
pub enabled: bool,
#[serde(default = "default_metric_discovery_enabled")]
pub discovery_enabled: bool,
#[serde(default = "default_metric_interval_s")]
pub interval_s: u64,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub unique_id: Option<String>,
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub device_class: Option<String>,
#[serde(default)]
pub state_class: Option<String>,
#[serde(default)]
pub icon: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -169,6 +617,19 @@ fn expand_variables(cfg: &mut Config) -> Result<()> {
cfg.mqtt.client_id = hostname.clone();
}
for metric in cfg.features.telemetry.metrics.values_mut() {
if let Some(name) = metric.name.as_mut() {
if name.contains("$hostname") {
*name = name.replace("$hostname", &hostname);
}
}
if let Some(unique_id) = metric.unique_id.as_mut() {
if unique_id.contains("$hostname") {
*unique_id = unique_id.replace("$hostname", &hostname);
}
}
}
Ok(())
}
@@ -186,6 +647,25 @@ fn validate(cfg: &Config) -> Result<()> {
if cfg.mqtt.discovery_prefix.trim().is_empty() {
bail!("mqtt.discovery_prefix must not be empty");
}
if cfg.mqtt.reconnect.attempts == 0 {
bail!("mqtt.reconnect.attempts must be > 0");
}
if cfg.mqtt.reconnect.retry_delay_s == 0 {
bail!("mqtt.reconnect.retry_delay_s must be > 0");
}
if cfg.mqtt.reconnect.short_wait_s == 0 {
bail!("mqtt.reconnect.short_wait_s must be > 0");
}
if cfg.mqtt.reconnect.long_wait_s == 0 {
bail!("mqtt.reconnect.long_wait_s must be > 0");
}
if cfg.features.telemetry.enabled {
for (name, metric) in &cfg.features.telemetry.metrics {
if metric.enabled && metric.interval_s == 0 {
bail!("telemetry.metrics.{}.interval_s must be > 0", name);
}
}
}
Ok(())
}
@@ -213,7 +693,10 @@ mqtt:
features:
telemetry:
enabled: true
interval_s: 5
metrics:
cpu_usage:
enabled: true
interval_s: 5
commands:
enabled: true
cooldown_s: 2
@@ -235,6 +718,7 @@ publish:
assert_eq!(cfg.device.name, "test");
assert_eq!(cfg.mqtt.port, 1883);
assert!(cfg.features.commands.dry_run);
assert!(cfg.features.telemetry.metrics.contains_key("cpu_usage"));
}
#[test]
@@ -257,7 +741,12 @@ mqtt:
features:
telemetry:
enabled: true
interval_s: 5
metrics:
cpu_usage:
enabled: true
interval_s: 5
name: "$hostname CPU Usage"
unique_id: "$hostname_cpu_usage"
commands:
enabled: true
cooldown_s: 2
@@ -276,7 +765,10 @@ publish:
let mut cfg: Config = serde_yaml::from_str(raw).unwrap();
expand_variables(&mut cfg).unwrap();
let hostname = get_hostname().unwrap();
let metric = cfg.features.telemetry.metrics.get("cpu_usage").unwrap();
assert_eq!(metric.unique_id.as_deref(), Some(&format!("{}_cpu_usage", hostname)));
assert_eq!(metric.name.as_deref(), Some(&format!("{} CPU Usage", hostname)));
let hostname = get_hostname().unwrap();
assert_eq!(cfg.device.name, hostname);
assert_eq!(cfg.device.identifiers[0], hostname);
+75 -47
View File
@@ -2,8 +2,10 @@
use anyhow::{Context, Result};
use rumqttc::AsyncClient;
use serde::Serialize;
use tracing::debug;
use crate::config::{base_device_topic, Config};
use crate::mqtt;
#[derive(Clone, Serialize)]
struct DeviceInfo {
@@ -17,26 +19,28 @@ struct DeviceInfo {
}
#[derive(Serialize)]
struct EntityConfig<'a> {
name: &'a str,
struct EntityConfig {
name: String,
unique_id: String,
state_topic: String,
availability_topic: String,
payload_available: &'a str,
payload_not_available: &'a str,
payload_available: String,
payload_not_available: String,
device: DeviceInfo,
#[serde(skip_serializing_if = "Option::is_none")]
command_topic: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
payload_on: Option<&'a str>,
payload_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
payload_off: Option<&'a str>,
payload_off: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
unit_of_measurement: Option<&'a str>,
unit_of_measurement: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
device_class: Option<&'a str>,
device_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<&'a str>,
state_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
}
// Publie les entites HA discovery pour les capteurs et commandes standard.
@@ -53,35 +57,38 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
};
let sensors = vec![
("cpu_usage", "CPU Usage", Some("%"), None, Some("mdi:chip")),
("memory_used_mb", "Memory Used", Some("MB"), None, Some("mdi:memory")),
("memory_total_mb", "Memory Total", Some("MB"), None, Some("mdi:memory")),
("ip_address", "IP Address", None, None, Some("mdi:ip")),
("power_state", "Power State", None, None, Some("mdi:power")),
("battery_level", "Battery Level", Some("%"), Some("battery"), Some("mdi:battery")),
("battery_state", "Battery State", None, None, Some("mdi:battery-charging")),
];
for (key, name, unit, class, icon) in sensors {
let entity_name = format!("{}_{}", key, cfg.device.name);
let entity = EntityConfig {
name: &entity_name,
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/{}", base, key),
availability_topic: format!("{}/{}/available", base, key),
payload_available: "online",
payload_not_available: "offline",
device: DeviceInfo { ..device.clone() },
command_topic: None,
payload_on: None,
payload_off: None,
unit_of_measurement: unit,
device_class: class,
icon,
};
let topic = format!("{}/sensor/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
if cfg.features.telemetry.enabled {
for (key, metric) in &cfg.features.telemetry.metrics {
if !metric.enabled {
continue;
}
if !metric.discovery_enabled {
continue;
}
let name = normalize_optional(&metric.name)
.unwrap_or_else(|| format!("{}_{}", key, cfg.device.name));
let unique_id = normalize_optional(&metric.unique_id)
.unwrap_or_else(|| format!("{}_{}", cfg.device.name, key));
let entity = EntityConfig {
name,
unique_id,
state_topic: format!("{}/{}", base, key),
availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(),
payload_not_available: "offline".to_string(),
device: DeviceInfo { ..device.clone() },
command_topic: None,
payload_on: None,
payload_off: None,
unit_of_measurement: normalize_unit(&metric.unit, &metric.device_class),
device_class: normalize_optional(&metric.device_class),
state_class: normalize_optional(&metric.state_class),
icon: normalize_optional(&metric.icon),
};
let entity_name = format!("{}_{}", key, cfg.device.name);
let topic = format!("{}/sensor/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
}
}
let switches = vec![
@@ -91,22 +98,23 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
("screen", "Screen", "cmd/screen/set"),
];
for (key, name, cmd) in switches {
for (key, _name, cmd) in switches {
let entity_name = format!("{}_{}", key, cfg.device.name);
let entity = EntityConfig {
name: &entity_name,
name: entity_name.clone(),
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/{}/state", base, key),
availability_topic: format!("{}/{}/available", base, key),
payload_available: "online",
payload_not_available: "offline",
availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(),
payload_not_available: "offline".to_string(),
device: DeviceInfo { ..device.clone() },
command_topic: Some(format!("{}/{}", base, cmd)),
payload_on: Some("ON"),
payload_off: Some("OFF"),
payload_on: Some("ON".to_string()),
payload_off: Some("OFF".to_string()),
unit_of_measurement: None,
device_class: Some("switch"),
icon: Some("mdi:power"),
device_class: Some("switch".to_string()),
state_class: None,
icon: Some("mdi:power".to_string()),
};
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
@@ -117,9 +125,29 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
async fn publish_discovery<T: Serialize>(client: &AsyncClient, topic: &str, payload: &T) -> Result<()> {
let data = serde_json::to_vec(payload).context("serialize discovery")?;
debug!(topic = %topic, bytes = data.len(), "publish discovery");
client
.publish(topic, rumqttc::QoS::AtLeastOnce, true, data)
.await
.context("publish discovery")?;
mqtt::record_publish();
Ok(())
}
fn normalize_optional(value: &Option<String>) -> Option<String> {
value
.as_ref()
.map(|item| item.trim())
.filter(|item| !item.is_empty())
.map(|item| item.to_string())
}
fn normalize_unit(unit: &Option<String>, device_class: &Option<String>) -> Option<String> {
let unit = normalize_optional(unit);
if matches!(normalize_optional(device_class).as_deref(), Some("temperature")) {
if matches!(unit.as_deref(), Some("C")) {
return Some("°C".to_string());
}
}
unit
}
+4 -3
View File
@@ -1,15 +1,16 @@
// Point d'entree principal de l'application.
use anyhow::Result;
use tracing::info;
use tracing_subscriber::EnvFilter;
use pilot_v2::config;
use pilot_v2::runtime::Runtime;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter("pilot_v2=info")
.init();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("pilot_v2=info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let config = config::load()?;
info!("config loaded");
+24 -1
View File
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
use rumqttc::{AsyncClient, EventLoop, LastWill, MqttOptions, QoS};
use serde::Serialize;
use std::time::Duration;
use std::sync::atomic::{AtomicU64, Ordering};
use tracing::{debug, info};
use crate::config::{base_device_topic, Config};
@@ -11,6 +13,15 @@ pub struct MqttHandle {
pub event_loop: EventLoop,
}
static PUBLISH_COUNT: AtomicU64 = AtomicU64::new(0);
pub fn take_publish_count() -> u64 {
PUBLISH_COUNT.swap(0, Ordering::Relaxed)
}
pub fn record_publish() {
PUBLISH_COUNT.fetch_add(1, Ordering::Relaxed);
}
#[derive(Debug, Serialize)]
pub struct Status {
pub version: String,
@@ -58,12 +69,15 @@ pub fn connect(cfg: &Config) -> Result<MqttHandle> {
// Publie availability en retained pour indiquer online/offline.
pub async fn publish_availability(client: &AsyncClient, cfg: &Config, online: bool) -> Result<()> {
let topic = format!("{}/availability", base_device_topic(cfg));
let base = base_device_topic(cfg);
let payload = if online { "online" } else { "offline" };
let topic = format!("{}/availability", base);
info!(topic = %topic, payload = %payload, "publishing availability");
client
.publish(topic, qos(cfg), true, payload)
.await
.context("publish availability")?;
record_publish();
Ok(())
}
@@ -75,10 +89,12 @@ pub async fn publish_status(
) -> Result<()> {
let topic = format!("{}/status", base_device_topic(cfg));
let payload = serde_json::to_vec(status).context("serialize status")?;
debug!(topic = %topic, bytes = payload.len(), "publish status");
client
.publish(topic, qos(cfg), true, payload)
.await
.context("publish status")?;
record_publish();
Ok(())
}
@@ -90,10 +106,12 @@ pub async fn publish_capabilities(
) -> Result<()> {
let topic = format!("{}/capabilities", base_device_topic(cfg));
let payload = serde_json::to_vec(capabilities).context("serialize capabilities")?;
debug!(topic = %topic, bytes = payload.len(), "publish capabilities");
client
.publish(topic, qos(cfg), true, payload)
.await
.context("publish capabilities")?;
record_publish();
Ok(())
}
@@ -105,10 +123,12 @@ pub async fn publish_state(
value: &str,
) -> Result<()> {
let topic = format!("{}/{}", base_device_topic(cfg), name);
debug!(topic = %topic, payload = %value, "publish state");
client
.publish(topic, qos(cfg), cfg.mqtt.retain_states, value)
.await
.context("publish state")?;
record_publish();
Ok(())
}
@@ -120,16 +140,19 @@ pub async fn publish_switch_state(
value: &str,
) -> Result<()> {
let topic = format!("{}/{}/state", base_device_topic(cfg), name);
debug!(topic = %topic, payload = %value, "publish switch state");
client
.publish(topic, qos(cfg), cfg.mqtt.retain_states, value)
.await
.context("publish switch state")?;
record_publish();
Ok(())
}
// S'abonne aux commandes standard (cmd/<action>/set).
pub async fn subscribe_commands(client: &AsyncClient, cfg: &Config) -> Result<()> {
let topic = format!("{}/cmd/+/set", base_device_topic(cfg));
debug!(topic = %topic, "subscribe commands");
client
.subscribe(topic, qos(cfg))
.await
+21 -6
View File
@@ -1,5 +1,6 @@
// Implementations Linux (logind, sudoers, gnome busctl, x11 xset).
use anyhow::{bail, Context, Result};
use tracing::debug;
use std::process::Command;
use crate::commands::{CommandAction, CommandValue};
@@ -55,21 +56,35 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
),
},
"x11_xset" => match value {
CommandValue::Off => run("xset", &["dpms", "force", "off"]),
CommandValue::On => run("xset", &["dpms", "force", "on"]),
CommandValue::Off => {
log_x11_env();
run("xset", &["dpms", "force", "off"])
}
CommandValue::On => {
log_x11_env();
run("xset", &["dpms", "force", "on"])
}
},
_ => bail!("unknown linux screen backend"),
}
}
fn run(cmd: &str, args: &[&str]) -> Result<()> {
let status = Command::new(cmd)
debug!(%cmd, args = ?args, "running command");
let output = Command::new(cmd)
.args(args)
.status()
.output()
.with_context(|| format!("failed to run {cmd}"))?;
if status.success() {
if output.status.success() {
Ok(())
} else {
bail!("command failed: {cmd}")
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("command failed: {cmd} ({}) {stderr}", output.status)
}
}
fn log_x11_env() {
let display_env = std::env::var("DISPLAY").unwrap_or_default();
let xauth_env = std::env::var("XAUTHORITY").unwrap_or_default();
debug!(display_env = %display_env, xauthority_env = %xauth_env, "x11 environment");
}
+135 -38
View File
@@ -1,10 +1,10 @@
// Ce module orchestre le cycle de vie de l'application.
use anyhow::Result;
use std::time::Instant;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::process::Command;
use tokio::time::{interval, sleep, Duration};
use tracing::warn;
use tracing::{debug, info, warn};
use crate::config::Config;
use crate::commands::{self, CommandAction, CommandValue};
@@ -18,6 +18,12 @@ pub struct Runtime {
start: Instant,
}
struct MetricSchedule {
name: String,
interval: Duration,
next_due: Instant,
}
impl Runtime {
// Cree un runtime avec la configuration chargee.
pub fn new(config: Config) -> Self {
@@ -34,38 +40,63 @@ impl Runtime {
let mut event_loop = handle.event_loop;
let client = handle.client;
// Wait for MQTT connection to be established
loop {
match event_loop.poll().await {
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
tracing::info!("mqtt connected");
break;
}
Ok(_) => continue,
Err(err) => {
tracing::warn!(error = %err, "mqtt connection error, retrying...");
sleep(Duration::from_secs(2)).await;
}
}
}
wait_for_mqtt_connection(&mut event_loop, &self.config).await;
// Spawn event loop handler in background to process messages
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
loop {
match event_loop.poll().await {
Ok(rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish))) => {
let payload = String::from_utf8_lossy(&publish.payload);
debug!(topic = %publish.topic, payload = %payload, "mqtt incoming publish");
let _ = cmd_tx.send((publish.topic.to_string(), publish.payload.to_vec()));
backoff = Duration::from_secs(1);
}
Ok(_) => {
backoff = Duration::from_secs(1);
}
Ok(_) => {}
Err(err) => {
tracing::warn!(error = %err, "mqtt eventloop error");
tokio::time::sleep(Duration::from_secs(2)).await;
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, Duration::from_secs(60));
}
}
}
});
let enabled_metrics: HashSet<String> = if self.config.features.telemetry.enabled {
self.config
.features
.telemetry
.metrics
.iter()
.filter_map(|(name, metric)| {
if metric.enabled {
Some(name.clone())
} else {
None
}
})
.collect()
} else {
HashSet::new()
};
let mut metric_schedule = Vec::new();
if self.config.features.telemetry.enabled {
for (name, metric) in &self.config.features.telemetry.metrics {
if !metric.enabled {
continue;
}
metric_schedule.push(MetricSchedule {
name: name.clone(),
interval: Duration::from_secs(metric.interval_s),
next_due: Instant::now(),
});
}
}
// Send initial messages
if self.config.publish.availability {
mqtt::publish_availability(&client, &self.config, true).await?;
@@ -81,11 +112,6 @@ impl Runtime {
publish_initial_command_states(&client, &self.config).await;
let initial_power_state = detect_power_state();
if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", &initial_power_state).await {
warn!(error = %err, "publish power_state failed");
}
if self.config.features.commands.enabled {
mqtt::subscribe_commands(&client, &self.config).await?;
}
@@ -97,12 +123,11 @@ impl Runtime {
} else {
None
};
let mut telemetry_tick = interval(Duration::from_secs(
self.config.features.telemetry.interval_s,
));
let mut telemetry_tick = interval(Duration::from_secs(1));
let mut heartbeat_tick = interval(Duration::from_secs(
self.config.publish.heartbeat_s,
));
let mut stats_tick = interval(Duration::from_secs(60));
let mut last_exec: HashMap<CommandAction, std::time::Instant> = HashMap::new();
let shutdown = tokio::signal::ctrl_c();
@@ -111,24 +136,47 @@ impl Runtime {
loop {
tokio::select! {
_ = telemetry_tick.tick(), if telemetry.is_some() => {
let metrics = telemetry.as_mut().unwrap().read();
for (name, value) in metrics {
if let Err(err) = mqtt::publish_state(&client, &self.config, &name, &value).await {
warn!(error = %err, "publish state failed");
if metric_schedule.is_empty() {
continue;
}
let now = Instant::now();
let mut due: HashSet<String> = HashSet::new();
for schedule in &mut metric_schedule {
if now >= schedule.next_due {
due.insert(schedule.name.clone());
schedule.next_due = now + schedule.interval;
}
}
if !due.is_empty() {
let power_state_due = due.remove("power_state");
let metrics = telemetry.as_mut().unwrap().read(&due);
for (name, value) in metrics {
if let Err(err) = mqtt::publish_state(&client, &self.config, &name, &value).await {
warn!(error = %err, "publish state failed");
}
}
if power_state_due && enabled_metrics.contains("power_state") {
let current = detect_power_state();
if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", &current).await {
warn!(error = %err, "publish power_state failed");
}
}
}
}
_ = heartbeat_tick.tick() => {
let current = detect_power_state();
if let Err(err) = mqtt::publish_state(&client, &self.config, "power_state", &current).await {
warn!(error = %err, "publish power_state failed");
}
let status = build_status(&self.config, self.start.elapsed().as_secs());
if let Err(err) = mqtt::publish_status(&client, &self.config, &status).await {
warn!(error = %err, "publish status failed");
}
}
_ = stats_tick.tick() => {
let published = mqtt::take_publish_count();
info!(count = published, "mqtt publish stats (last 60s)");
}
Some((topic, payload)) = cmd_rx.recv() => {
let payload_str = String::from_utf8_lossy(&payload);
debug!(%topic, payload = %payload_str, "command received from mqtt");
if let Err(err) = handle_command(
&client,
&self.config,
@@ -153,13 +201,59 @@ impl Runtime {
}
}
async fn wait_for_mqtt_connection(event_loop: &mut rumqttc::EventLoop, cfg: &Config) {
let policy = &cfg.mqtt.reconnect;
let retry_delay = Duration::from_secs(policy.retry_delay_s);
let short_wait = Duration::from_secs(policy.short_wait_s);
let long_wait = Duration::from_secs(policy.long_wait_s);
let mut attempts = 0u32;
let mut used_short_wait = false;
loop {
match event_loop.poll().await {
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
info!("mqtt connected");
break;
}
Ok(_) => continue,
Err(err) => {
attempts = attempts.saturating_add(1);
warn!(error = %err, attempt = attempts, max_attempts = policy.attempts, "mqtt connection error");
if attempts < policy.attempts {
sleep(retry_delay).await;
continue;
}
attempts = 0;
if !used_short_wait {
info!(
wait_s = policy.short_wait_s,
"mqtt unavailable, waiting before next retry batch"
);
sleep(short_wait).await;
used_short_wait = true;
} else {
info!(
wait_s = policy.long_wait_s,
"mqtt unavailable, waiting longer before next retry batch"
);
sleep(long_wait).await;
}
}
}
}
}
// Genere les capacites declarees par le programme.
fn capabilities(cfg: &Config) -> Capabilities {
let mut telemetry = Vec::new();
if cfg.features.telemetry.enabled {
telemetry.push("cpu_usage".to_string());
telemetry.push("cpu_temp".to_string());
telemetry.push("memory".to_string());
telemetry = cfg
.features
.telemetry
.metrics
.iter()
.filter_map(|(name, metric)| if metric.enabled { Some(name.clone()) } else { None })
.collect();
}
let mut commands = Vec::new();
@@ -262,6 +356,7 @@ async fn handle_command(
) -> anyhow::Result<()> {
let action = commands::parse_action(topic)?;
let value = commands::parse_value(payload)?;
debug!(%topic, ?action, ?value, "command received");
if !commands::allowlist_allows(&cfg.features.commands.allowlist, action) {
return Ok(());
@@ -300,7 +395,9 @@ async fn handle_command(
}
}
CommandAction::Screen => {
platform::execute_screen(&backend_screen(cfg), value)?;
let backend = backend_screen(cfg);
debug!(backend = %backend, ?value, "executing screen command");
platform::execute_screen(&backend, value)?;
publish_command_state(client, cfg, action, value).await?;
}
}
+459 -24
View File
@@ -1,19 +1,22 @@
// Ce module declare l'interface de telemetrie et une implementation basique.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::process::Command;
use local_ip_address::local_ip;
use sysinfo::System;
use sysinfo::{Components, Disks, System};
// Retourne un dictionnaire simple {nom -> valeur} pour les capteurs.
pub trait TelemetryProvider {
fn read(&mut self) -> HashMap<String, String>;
fn read(&mut self, metrics: &HashSet<String>) -> HashMap<String, String>;
}
// Telemetrie basique cross-platform (cpu, memoire, ip, batterie).
pub struct BasicTelemetry {
system: System,
disks: Disks,
components: Components,
}
impl BasicTelemetry {
@@ -21,32 +24,156 @@ impl BasicTelemetry {
pub fn new() -> Self {
let mut system = System::new();
system.refresh_all();
Self { system }
let disks = Disks::new_with_refreshed_list();
let components = Components::new_with_refreshed_list();
Self {
system,
disks,
components,
}
}
}
impl TelemetryProvider for BasicTelemetry {
fn read(&mut self) -> HashMap<String, String> {
self.system.refresh_cpu();
self.system.refresh_memory();
fn read(&mut self, metrics: &HashSet<String>) -> HashMap<String, String> {
let mut values = HashMap::new();
let cpu = self.system.global_cpu_info().cpu_usage();
let mem_used_mb = self.system.used_memory() / 1024;
let mem_total_mb = self.system.total_memory() / 1024;
values.insert("cpu_usage".to_string(), format!("{:.1}", cpu));
values.insert("memory_used_mb".to_string(), mem_used_mb.to_string());
values.insert("memory_total_mb".to_string(), mem_total_mb.to_string());
if let Ok(ip) = local_ip() {
values.insert("ip_address".to_string(), ip.to_string());
if metrics.contains("cpu_usage") {
self.system.refresh_cpu();
let cpu = self.system.global_cpu_info().cpu_usage();
values.insert("cpu_usage".to_string(), format!("{:.1}", cpu));
}
// Add battery info if available
if let Some(battery) = read_battery_info() {
values.insert("battery_level".to_string(), battery.level.to_string());
values.insert("battery_state".to_string(), battery.state);
if metrics.contains("memory_used_gb") || metrics.contains("memory_total_gb") {
self.system.refresh_memory();
let mem_used_gb = self.system.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
let mem_total_gb = self.system.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
if metrics.contains("memory_used_gb") {
values.insert("memory_used_gb".to_string(), format!("{:.2}", mem_used_gb));
}
if metrics.contains("memory_total_gb") {
values.insert("memory_total_gb".to_string(), format!("{:.2}", mem_total_gb));
}
}
if metrics.contains("ip_address") {
if let Ok(ip) = local_ip() {
values.insert("ip_address".to_string(), ip.to_string());
}
}
let wants_pilot_cpu = metrics.contains("pilot_v2_cpu_usage");
let wants_pilot_mem = metrics.contains("pilot_v2_mem_used_mb");
if wants_pilot_cpu || wants_pilot_mem {
self.system.refresh_processes();
let (cpu_usage, mem_used_mb) =
read_process_stats(&self.system, &["pilot-v2", "pilot_v2"]);
if wants_pilot_cpu {
values.insert("pilot_v2_cpu_usage".to_string(), format!("{:.1}", cpu_usage));
}
if wants_pilot_mem {
values.insert("pilot_v2_mem_used_mb".to_string(), format!("{:.2}", mem_used_mb));
}
}
if metrics.contains("kernel") || metrics.contains("kernel_version") {
if let Some(kernel) = System::kernel_version() {
if metrics.contains("kernel") {
values.insert("kernel".to_string(), kernel.clone());
}
if metrics.contains("kernel_version") {
values.insert("kernel_version".to_string(), kernel);
}
}
}
if metrics.contains("os_version") || metrics.contains("os") {
let os = System::long_os_version().or_else(System::os_version);
if let Some(version) = os {
if metrics.contains("os_version") {
values.insert("os_version".to_string(), version.clone());
}
if metrics.contains("os") {
values.insert("os".to_string(), version);
}
}
}
let wants_cpu_temp = metrics.contains("cpu_temp_c");
let wants_ssd_temp = metrics.contains("ssd_temp_c");
let wants_amd_gpu_temp = metrics.contains("amd_gpu_temp_c");
if wants_cpu_temp || wants_ssd_temp || wants_amd_gpu_temp {
self.components.refresh();
if wants_cpu_temp {
if let Some(temp) = read_component_temp(&self.components, &["cpu", "package", "tctl", "core"]) {
values.insert("cpu_temp_c".to_string(), format!("{:.1}", temp));
}
}
if wants_ssd_temp {
if let Some(temp) = read_component_temp(&self.components, &["nvme", "ssd", "disk", "drive"]) {
values.insert("ssd_temp_c".to_string(), format!("{:.1}", temp));
}
}
if wants_amd_gpu_temp {
if let Some(temp) = read_component_temp(&self.components, &["amdgpu", "edge"]) {
values.insert("amd_gpu_temp_c".to_string(), format!("{:.1}", temp));
}
}
}
let wants_fan_cpu = metrics.contains("fan_cpu_rpm");
let wants_fan_gpu = metrics.contains("fan_gpu_rpm");
if wants_fan_cpu {
if let Some(rpm) = read_fan_rpm(&["cpu"]) {
values.insert("fan_cpu_rpm".to_string(), rpm.to_string());
}
}
if wants_fan_gpu {
if let Some(rpm) = read_fan_rpm(&["gpu", "vga"]) {
values.insert("fan_gpu_rpm".to_string(), rpm.to_string());
}
}
if metrics.contains("disk_free_gb") {
self.disks.refresh();
if let Some(free_gb) = read_disk_free_gb(&self.disks) {
values.insert("disk_free_gb".to_string(), format!("{:.2}", free_gb));
}
}
if metrics.contains("amd_gpu_usage") {
if let Some(usage) = read_amd_gpu_busy_percent() {
values.insert("amd_gpu_usage".to_string(), usage.to_string());
}
}
if metrics.contains("amd_gpu_mem_used_gb") {
if let Some(mem_gb) = read_amd_gpu_mem_used_gb() {
values.insert("amd_gpu_mem_used_gb".to_string(), format!("{:.2}", mem_gb));
}
}
if wants_gpu_metrics(metrics) {
if let Some(stats) = read_nvidia_gpu_stats() {
if metrics.contains("gpu_usage") {
let avg = stats.iter().map(|item| item.utilization as u64).sum::<u64>() as f64
/ stats.len() as f64;
values.insert("gpu_usage".to_string(), format!("{:.1}", avg));
}
insert_gpu_metric(&mut values, metrics, &stats, 0);
insert_gpu_metric(&mut values, metrics, &stats, 1);
}
}
if metrics.contains("battery_level") || metrics.contains("battery_state") {
if let Some(battery) = read_battery_info() {
if metrics.contains("battery_level") {
values.insert("battery_level".to_string(), battery.level.to_string());
}
if metrics.contains("battery_state") {
values.insert("battery_state".to_string(), battery.state);
}
}
}
values
@@ -126,7 +253,315 @@ fn read_battery_linux() -> Option<BatteryInfo> {
#[cfg(target_os = "windows")]
fn read_battery_windows() -> Option<BatteryInfo> {
// TODO: Implement Windows battery reading via GetSystemPowerStatus
// For now, return None
None
use windows_sys::Win32::System::Power::{GetSystemPowerStatus, SYSTEM_POWER_STATUS};
let mut status = SYSTEM_POWER_STATUS {
ACLineStatus: 0,
BatteryFlag: 0,
BatteryLifePercent: 0,
SystemStatusFlag: 0,
BatteryLifeTime: 0,
BatteryFullLifeTime: 0,
};
let ok = unsafe { GetSystemPowerStatus(&mut status as *mut _) };
if ok == 0 {
return None;
}
if status.BatteryFlag == 0x80 || status.BatteryLifePercent == 255 {
return None;
}
let level = status.BatteryLifePercent as u8;
let state = if status.ACLineStatus == 1 {
if level >= 100 {
"full"
} else if (status.BatteryFlag & 0x08) != 0 {
"charging"
} else {
"not_charging"
}
} else if status.ACLineStatus == 0 {
"discharging"
} else {
"unknown"
};
Some(BatteryInfo {
level,
state: state.to_string(),
})
}
fn read_disk_free_gb(disks: &Disks) -> Option<f64> {
let free_bytes: u64 = disks
.list()
.iter()
.filter(|disk| !disk.is_removable())
.map(|disk| disk.available_space())
.sum();
if free_bytes == 0 {
return None;
}
Some(free_bytes as f64 / 1024.0 / 1024.0 / 1024.0)
}
fn read_fan_rpm(keywords: &[&str]) -> Option<u64> {
#[cfg(target_os = "linux")]
{
read_fan_rpm_linux(keywords)
}
#[cfg(not(target_os = "linux"))]
{
let _ = keywords;
None
}
}
#[cfg(target_os = "linux")]
fn read_fan_rpm_linux(keywords: &[&str]) -> Option<u64> {
let hwmon_path = Path::new("/sys/class/hwmon");
if !hwmon_path.exists() {
return None;
}
let mut best: Option<u64> = None;
let keywords: Vec<String> = keywords.iter().map(|item| item.to_lowercase()).collect();
for entry in fs::read_dir(hwmon_path).ok()?.flatten() {
let path = entry.path();
let hwmon_name = fs::read_to_string(path.join("name"))
.unwrap_or_default()
.to_lowercase();
let entries = match fs::read_dir(&path) {
Ok(entries) => entries,
Err(_) => continue,
};
for file in entries.flatten() {
let file_name = file.file_name();
let file_name = file_name.to_string_lossy();
if !file_name.starts_with("fan") || !file_name.ends_with("_input") {
continue;
}
let rpm = match fs::read_to_string(file.path()) {
Ok(value) => match value.trim().parse::<u64>() {
Ok(value) => value,
Err(_) => continue,
},
Err(_) => continue,
};
let label_path = path.join(file_name.replace("_input", "_label"));
let label = fs::read_to_string(label_path)
.unwrap_or_default()
.to_lowercase();
let haystack = format!("{} {}", hwmon_name, label);
let matched = keywords.iter().any(|key| haystack.contains(key));
if matched {
best = Some(best.map_or(rpm, |current| current.max(rpm)));
}
}
}
best
}
fn wants_gpu_metrics(metrics: &HashSet<String>) -> bool {
metrics.contains("gpu_usage")
|| metrics.contains("gpu0_usage")
|| metrics.contains("gpu1_usage")
|| metrics.contains("gpu0_mem_used_gb")
|| metrics.contains("gpu1_mem_used_gb")
|| metrics.contains("gpu0_temp_c")
|| metrics.contains("gpu1_temp_c")
}
#[derive(Debug)]
struct NvidiaGpuStats {
utilization: u8,
memory_used_mb: u64,
temperature_c: u32,
}
fn read_nvidia_gpu_stats() -> Option<Vec<NvidiaGpuStats>> {
let output = Command::new("nvidia-smi")
.args([
"--query-gpu=utilization.gpu,memory.used,temperature.gpu",
"--format=csv,noheader,nounits",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut stats = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split(',').map(|part| part.trim()).collect();
if parts.len() != 3 {
continue;
}
let utilization: u8 = parts[0].parse().ok()?;
let memory_used_mb: u64 = parts[1].parse().ok()?;
let temperature_c: u32 = parts[2].parse().ok()?;
stats.push(NvidiaGpuStats {
utilization,
memory_used_mb,
temperature_c,
});
}
if stats.is_empty() {
None
} else {
Some(stats)
}
}
fn insert_gpu_metric(
values: &mut HashMap<String, String>,
metrics: &HashSet<String>,
stats: &[NvidiaGpuStats],
index: usize,
) {
if stats.len() <= index {
return;
}
let gpu = &stats[index];
let usage_key = format!("gpu{}_usage", index);
if metrics.contains(&usage_key) {
values.insert(usage_key, gpu.utilization.to_string());
}
let mem_key = format!("gpu{}_mem_used_gb", index);
if metrics.contains(&mem_key) {
let mem_gb = gpu.memory_used_mb as f64 / 1024.0;
values.insert(mem_key, format!("{:.2}", mem_gb));
}
let temp_key = format!("gpu{}_temp_c", index);
if metrics.contains(&temp_key) {
values.insert(temp_key, gpu.temperature_c.to_string());
}
}
fn read_component_temp(components: &Components, keywords: &[&str]) -> Option<f32> {
let mut best: Option<f32> = None;
for component in components.list() {
let label = component.label().to_lowercase();
if !keywords.iter().any(|key| label.contains(key)) {
continue;
}
let temp = component.temperature();
if temp.is_nan() {
continue;
}
best = Some(best.map_or(temp, |current| current.max(temp)));
}
best
}
fn read_process_stats(system: &System, names: &[&str]) -> (f32, f64) {
let mut cpu_total = 0.0_f32;
let mut mem_total = 0_u64;
let names: Vec<String> = names.iter().map(|name| name.to_lowercase()).collect();
for process in system.processes().values() {
let pname = process.name().to_lowercase();
if names.iter().any(|name| pname == *name) {
cpu_total += process.cpu_usage();
mem_total = mem_total.saturating_add(process.memory());
}
}
let mem_mb = mem_total as f64 / 1024.0 / 1024.0;
(cpu_total, mem_mb)
}
fn read_amd_gpu_busy_percent() -> Option<u8> {
#[cfg(target_os = "linux")]
{
read_sysfs_amd_gpu_busy()
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
fn read_amd_gpu_mem_used_gb() -> Option<f64> {
#[cfg(target_os = "linux")]
{
read_sysfs_amd_gpu_mem_used_gb()
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(target_os = "linux")]
fn read_sysfs_amd_gpu_busy() -> Option<u8> {
let drm_path = Path::new("/sys/class/drm");
if !drm_path.exists() {
return None;
}
let mut best: Option<u8> = None;
for entry in fs::read_dir(drm_path).ok()?.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("card") {
continue;
}
let busy_path = entry.path().join("device/gpu_busy_percent");
let value = match fs::read_to_string(busy_path) {
Ok(value) => value.trim().parse::<u8>().ok(),
Err(_) => None,
};
if let Some(value) = value {
best = Some(best.map_or(value, |current| current.max(value)));
}
}
best
}
#[cfg(target_os = "linux")]
fn read_sysfs_amd_gpu_mem_used_gb() -> Option<f64> {
let drm_path = Path::new("/sys/class/drm");
if !drm_path.exists() {
return None;
}
let mut best: Option<u64> = None;
for entry in fs::read_dir(drm_path).ok()?.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("card") {
continue;
}
let mem_path = entry.path().join("device/mem_info_vram_used");
let value = match fs::read_to_string(mem_path) {
Ok(value) => value.trim().parse::<u64>().ok(),
Err(_) => None,
};
if let Some(value) = value {
best = Some(best.map_or(value, |current| current.max(value)));
}
}
best.map(|bytes| bytes as f64 / 1024.0 / 1024.0 / 1024.0)
}