corrige temperature
This commit is contained in:
+495
-3
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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", ¤t).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", ¤t).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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user