Pilot v2: Core implementation + battery telemetry

Major updates:
- Complete Rust rewrite (pilot-v2/) with working MQTT client
- Fixed MQTT event loop deadlock (background task pattern)
- Battery telemetry for Linux (auto-detected via /sys/class/power_supply)
- Home Assistant auto-discovery for all sensors and switches
- Comprehensive documentation (AVANCEMENT.md, CLAUDE.md, roadmap)
- Docker test environment with Mosquitto broker
- Helper scripts for development and testing

Features working:
 MQTT connectivity with LWT
 YAML configuration with validation
 Telemetry: CPU, memory, IP, battery (Linux)
 Commands: shutdown, reboot, sleep, screen (dry-run tested)
 HA discovery and integration
 Allowlist and cooldown protection

Ready for testing on real hardware.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Gilles Soulier
2025-12-30 06:23:00 +01:00
parent b7e67c6501
commit c5381b7112
4453 changed files with 78647 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
// Ce module declare les interfaces des commandes systeme et le parsing basique.
use anyhow::{bail, Result};
use std::time::{Duration, Instant};
use tracing::info;
// Actions d'alimentation supportees (shutdown/reboot/sleep).
pub trait PowerControl {
fn shutdown(&self) -> Result<()>;
fn reboot(&self) -> Result<()>;
fn sleep(&self) -> Result<()>;
}
// Actions d'ecran supportees (on/off).
pub trait ScreenControl {
fn screen_on(&self) -> Result<()>;
fn screen_off(&self) -> Result<()>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CommandAction {
Shutdown,
Reboot,
Sleep,
Screen,
}
#[derive(Debug, Clone, Copy)]
pub enum CommandValue {
On,
Off,
}
// Decode une action depuis le topic cmd/<action>/set.
pub fn parse_action(topic: &str) -> Result<CommandAction> {
let parts: Vec<&str> = topic.split('/').collect();
if parts.len() < 2 {
bail!("topic too short");
}
let action = parts[parts.len() - 2];
match action {
"shutdown" => Ok(CommandAction::Shutdown),
"reboot" => Ok(CommandAction::Reboot),
"sleep" => Ok(CommandAction::Sleep),
"screen" => Ok(CommandAction::Screen),
_ => bail!("unknown action"),
}
}
// Decode une valeur ON/OFF (insensible a la casse).
pub fn parse_value(payload: &[u8]) -> Result<CommandValue> {
let raw = String::from_utf8_lossy(payload).trim().to_uppercase();
match raw.as_str() {
"ON" => Ok(CommandValue::On),
"OFF" => Ok(CommandValue::Off),
_ => bail!("invalid payload"),
}
}
// Verifie si l'action est autorisee par l'allowlist (vide = tout autoriser).
pub fn allowlist_allows(allowlist: &[String], action: CommandAction) -> bool {
if allowlist.is_empty() {
return true;
}
let name = action_name(action);
allowlist.iter().any(|item| item == name)
}
// Verifie le cooldown et renvoie true si l'action est autorisee.
pub fn allow_command(
last_exec: &mut std::collections::HashMap<CommandAction, Instant>,
cooldown_s: u64,
action: CommandAction,
) -> bool {
let now = Instant::now();
if let Some(prev) = last_exec.get(&action) {
if now.duration_since(*prev) < Duration::from_secs(cooldown_s) {
return false;
}
}
last_exec.insert(action, now);
true
}
// Execute une commande en mode dry-run (journalise seulement).
pub fn execute_dry_run(action: CommandAction, value: CommandValue) -> Result<()> {
info!(?action, ?value, "dry-run command");
Ok(())
}
// Convertit une action en nom utilise par la config.
pub fn action_name(action: CommandAction) -> &'static str {
match action {
CommandAction::Shutdown => "shutdown",
CommandAction::Reboot => "reboot",
CommandAction::Sleep => "sleep",
CommandAction::Screen => "screen",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_action_ok() {
let topic = "pilot/device/cmd/shutdown/set";
assert_eq!(parse_action(topic).unwrap(), CommandAction::Shutdown);
}
#[test]
fn parse_value_ok() {
assert!(matches!(parse_value(b"ON").unwrap(), CommandValue::On));
assert!(matches!(parse_value(b"off").unwrap(), CommandValue::Off));
}
#[test]
fn allowlist_checks() {
let list = vec!["shutdown".to_string(), "screen".to_string()];
assert!(allowlist_allows(&list, CommandAction::Shutdown));
assert!(!allowlist_allows(&list, CommandAction::Reboot));
}
#[test]
fn cooldown_blocks_second_call() {
let mut last_exec = std::collections::HashMap::new();
assert!(allow_command(&mut last_exec, 60, CommandAction::Shutdown));
assert!(!allow_command(&mut last_exec, 60, CommandAction::Shutdown));
}
}
+183
View File
@@ -0,0 +1,183 @@
// Ce module charge et valide la configuration YAML du projet.
// Il expose des structures de donnees simples pour le reste du code.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub device: Device,
pub mqtt: Mqtt,
pub features: Features,
pub power_backend: PowerBackend,
pub screen_backend: ScreenBackend,
pub publish: Publish,
pub paths: Option<Paths>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Device {
pub name: String,
pub identifiers: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Mqtt {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub base_topic: String,
pub discovery_prefix: String,
pub client_id: String,
pub keepalive_s: u64,
pub qos: u8,
pub retain_states: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Features {
pub telemetry: Telemetry,
pub commands: Commands,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Telemetry {
pub enabled: bool,
pub interval_s: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Commands {
pub enabled: bool,
pub cooldown_s: u64,
pub dry_run: bool,
pub allowlist: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PowerBackend {
pub linux: String,
pub windows: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScreenBackend {
pub linux: String,
pub windows: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Publish {
pub heartbeat_s: u64,
pub availability: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Paths {
pub linux_config: String,
pub windows_config: String,
}
// Charge la config depuis les chemins par defaut (OS + fallback).
pub fn load() -> Result<Config> {
let candidates = candidate_paths();
for path in candidates {
if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed reading config {}", path.display()))?;
let cfg: Config = serde_yaml::from_str(&raw)
.with_context(|| format!("failed parsing config {}", path.display()))?;
validate(&cfg)?;
return Ok(cfg);
}
}
bail!("no config file found in default locations");
}
// Liste les chemins de config a tester en premier.
pub fn candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if cfg!(target_os = "windows") {
paths.push(PathBuf::from(r"C:\ProgramData\Pilot\config.yaml"));
} else {
paths.push(PathBuf::from("/etc/pilot/config.yaml"));
}
paths.push(PathBuf::from("./config.yaml"));
paths
}
// Construit la racine des topics MQTT pour le device.
pub fn base_device_topic(cfg: &Config) -> String {
let base = cfg.mqtt.base_topic.trim_end_matches('/');
format!("{}/{}", base, cfg.device.name)
}
// Verifie les champs minimum pour eviter les erreurs au demarrage.
fn validate(cfg: &Config) -> Result<()> {
if cfg.device.name.trim().is_empty() {
bail!("device.name must not be empty");
}
if cfg.mqtt.host.trim().is_empty() {
bail!("mqtt.host must not be empty");
}
if cfg.mqtt.base_topic.trim().is_empty() {
bail!("mqtt.base_topic must not be empty");
}
if cfg.mqtt.discovery_prefix.trim().is_empty() {
bail!("mqtt.discovery_prefix must not be empty");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_config_from_yaml() {
let raw = r#"
device:
name: "test"
identifiers: ["test"]
mqtt:
host: "127.0.0.1"
port: 1883
username: ""
password: ""
base_topic: "pilot"
discovery_prefix: "homeassistant"
client_id: "test"
keepalive_s: 60
qos: 0
retain_states: true
features:
telemetry:
enabled: true
interval_s: 5
commands:
enabled: true
cooldown_s: 2
dry_run: true
allowlist: ["shutdown"]
power_backend:
linux: "linux_logind_polkit"
windows: "windows_service"
screen_backend:
linux: "gnome_busctl"
windows: "winapi_session"
publish:
heartbeat_s: 10
availability: true
"#;
let cfg: Config = serde_yaml::from_str(raw).unwrap();
validate(&cfg).unwrap();
assert_eq!(cfg.device.name, "test");
assert_eq!(cfg.mqtt.port, 1883);
assert!(cfg.features.commands.dry_run);
}
}
+115
View File
@@ -0,0 +1,115 @@
// Ce module regroupe la publication Home Assistant discovery.
use anyhow::{Context, Result};
use rumqttc::AsyncClient;
use serde::Serialize;
use crate::config::{base_device_topic, Config};
#[derive(Clone, Serialize)]
struct DeviceInfo {
identifiers: Vec<String>,
name: String,
manufacturer: String,
model: String,
sw_version: String,
}
#[derive(Serialize)]
struct EntityConfig<'a> {
name: &'a str,
unique_id: String,
state_topic: String,
availability_topic: 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>,
#[serde(skip_serializing_if = "Option::is_none")]
payload_off: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
unit_of_measurement: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
device_class: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<&'a str>,
}
// Publie les entites HA discovery pour les capteurs et commandes standard.
pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
let base = base_device_topic(cfg);
let prefix = cfg.mqtt.discovery_prefix.trim_end_matches('/');
let device = DeviceInfo {
identifiers: cfg.device.identifiers.clone(),
name: cfg.device.name.clone(),
manufacturer: "Pilot".to_string(),
model: "v2".to_string(),
sw_version: "2.0.0".to_string(),
};
let availability = format!("{}/availability", base);
let sensors = vec![
("cpu_usage", "CPU Usage", Some("%"), Some("power"), 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 = EntityConfig {
name,
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/state/{}", base, key),
availability_topic: availability.clone(),
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/{}/{}_{}", prefix, cfg.device.name, cfg.device.name, key);
publish_discovery(client, &topic, &entity).await?;
}
let switches = vec![
("shutdown", "Shutdown", "cmd/shutdown/set"),
("reboot", "Reboot", "cmd/reboot/set"),
("sleep", "Sleep", "cmd/sleep/set"),
("screen", "Screen", "cmd/screen/set"),
];
for (key, name, cmd) in switches {
let entity = EntityConfig {
name,
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/state/{}", base, key),
availability_topic: availability.clone(),
device: DeviceInfo { ..device.clone() },
command_topic: Some(format!("{}/{}", base, cmd)),
payload_on: Some("ON"),
payload_off: Some("OFF"),
unit_of_measurement: None,
device_class: Some("switch"),
icon: Some("mdi:power"),
};
let topic = format!("{}/switch/{}/{}_{}", prefix, cfg.device.name, cfg.device.name, key);
publish_discovery(client, &topic, &entity).await?;
}
Ok(())
}
async fn publish_discovery<T: Serialize>(client: &AsyncClient, topic: &str, payload: &T) -> Result<()> {
let data = serde_json::to_vec(payload).context("serialize discovery")?;
client
.publish(topic, rumqttc::QoS::AtLeastOnce, true, data)
.await
.context("publish discovery")?;
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
// Modules de base exposes au binaire principal.
pub mod config;
pub mod mqtt;
pub mod ha;
pub mod telemetry;
pub mod commands;
pub mod platform;
pub mod runtime;
pub mod security;
+19
View File
@@ -0,0 +1,19 @@
// Point d'entree principal de l'application.
use anyhow::Result;
use tracing::info;
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 config = config::load()?;
info!("config loaded");
let runtime = Runtime::new(config);
runtime.run().await
}
+132
View File
@@ -0,0 +1,132 @@
// Ce module gere la connexion MQTT et les publications de base.
use anyhow::{Context, Result};
use rumqttc::{AsyncClient, EventLoop, LastWill, MqttOptions, QoS};
use serde::Serialize;
use std::time::Duration;
use crate::config::{base_device_topic, Config};
pub struct MqttHandle {
pub client: AsyncClient,
pub event_loop: EventLoop,
}
#[derive(Debug, Serialize)]
pub struct Status {
pub version: String,
pub os: String,
pub uptime_s: u64,
pub last_error: String,
pub backends: Backends,
}
#[derive(Debug, Serialize)]
pub struct Backends {
pub power: String,
pub screen: String,
}
#[derive(Debug, Serialize)]
pub struct Capabilities {
pub telemetry: Vec<String>,
pub commands: Vec<String>,
pub gpu: bool,
}
// Cree un client MQTT configure selon le YAML.
pub fn connect(cfg: &Config) -> Result<MqttHandle> {
let client_id = if cfg.mqtt.client_id.trim().is_empty() {
cfg.device.name.clone()
} else {
cfg.mqtt.client_id.clone()
};
let mut options = MqttOptions::new(client_id, cfg.mqtt.host.clone(), cfg.mqtt.port);
options.set_keep_alive(Duration::from_secs(cfg.mqtt.keepalive_s));
if !cfg.mqtt.username.trim().is_empty() || !cfg.mqtt.password.trim().is_empty() {
options.set_credentials(cfg.mqtt.username.clone(), cfg.mqtt.password.clone());
}
let will_topic = format!("{}/availability", base_device_topic(cfg));
let will = LastWill::new(will_topic, "offline", qos(cfg), true);
options.set_last_will(will);
let (client, event_loop) = AsyncClient::new(options, 10);
Ok(MqttHandle { client, event_loop })
}
// 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 payload = if online { "online" } else { "offline" };
client
.publish(topic, qos(cfg), true, payload)
.await
.context("publish availability")?;
Ok(())
}
// Publie un status JSON (version, OS, backends, etc.).
pub async fn publish_status(
client: &AsyncClient,
cfg: &Config,
status: &Status,
) -> Result<()> {
let topic = format!("{}/status", base_device_topic(cfg));
let payload = serde_json::to_vec(status).context("serialize status")?;
client
.publish(topic, qos(cfg), true, payload)
.await
.context("publish status")?;
Ok(())
}
// Publie les capacites actives (telemetrie/commandes).
pub async fn publish_capabilities(
client: &AsyncClient,
cfg: &Config,
capabilities: &Capabilities,
) -> Result<()> {
let topic = format!("{}/capabilities", base_device_topic(cfg));
let payload = serde_json::to_vec(capabilities).context("serialize capabilities")?;
client
.publish(topic, qos(cfg), true, payload)
.await
.context("publish capabilities")?;
Ok(())
}
// Publie une valeur de capteur ou d'etat systeme.
pub async fn publish_state(
client: &AsyncClient,
cfg: &Config,
name: &str,
value: &str,
) -> Result<()> {
let topic = format!("{}/state/{}", base_device_topic(cfg), name);
client
.publish(topic, qos(cfg), cfg.mqtt.retain_states, value)
.await
.context("publish state")?;
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));
client
.subscribe(topic, qos(cfg))
.await
.context("subscribe commands")?;
Ok(())
}
// Convertit le QoS configure (0/1/2) en enum rumqttc.
fn qos(cfg: &Config) -> QoS {
match cfg.mqtt.qos {
1 => QoS::AtLeastOnce,
2 => QoS::ExactlyOnce,
_ => QoS::AtMostOnce,
}
}
+75
View File
@@ -0,0 +1,75 @@
// Implementations Linux (logind, sudoers, gnome busctl, x11 xset).
use anyhow::{bail, Context, Result};
use std::process::Command;
use crate::commands::{CommandAction, CommandValue};
// Execute une commande d'alimentation selon le backend choisi.
pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
match backend {
"linux_logind_polkit" => match action {
CommandAction::Shutdown => run("systemctl", &["poweroff"]),
CommandAction::Reboot => run("systemctl", &["reboot"]),
CommandAction::Sleep => run("systemctl", &["suspend"]),
CommandAction::Screen => bail!("screen action not supported in power backend"),
},
"linux_sudoers" => match action {
CommandAction::Shutdown => run("shutdown", &["-h", "now"]),
CommandAction::Reboot => run("reboot", &[]),
CommandAction::Sleep => run("systemctl", &["suspend"]),
CommandAction::Screen => bail!("screen action not supported in power backend"),
},
_ => bail!("unknown linux power backend"),
}
}
// Execute une commande d'ecran selon le backend choisi.
pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
match backend {
"gnome_busctl" => match value {
CommandValue::Off => run(
"busctl",
&[
"--user",
"set-property",
"org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig",
"org.gnome.Mutter.DisplayConfig",
"PowerSaveMode",
"i",
"1",
],
),
CommandValue::On => run(
"busctl",
&[
"--user",
"set-property",
"org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig",
"org.gnome.Mutter.DisplayConfig",
"PowerSaveMode",
"i",
"0",
],
),
},
"x11_xset" => match value {
CommandValue::Off => run("xset", &["dpms", "force", "off"]),
CommandValue::On => run("xset", &["dpms", "force", "on"]),
},
_ => bail!("unknown linux screen backend"),
}
}
fn run(cmd: &str, args: &[&str]) -> Result<()> {
let status = Command::new(cmd)
.args(args)
.status()
.with_context(|| format!("failed to run {cmd}"))?;
if status.success() {
Ok(())
} else {
bail!("command failed: {cmd}")
}
}
+26
View File
@@ -0,0 +1,26 @@
// Ce module selectionne les backends selon l'OS.
// Les sous-modules linux/windows implementent les commandes concretes.
use anyhow::Result;
use crate::commands::{CommandAction, CommandValue};
pub mod linux;
pub mod windows;
// Execute une commande d'alimentation (shutdown/reboot/sleep).
pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
if cfg!(target_os = "windows") {
windows::execute_power(backend, action)
} else {
linux::execute_power(backend, action)
}
}
// Execute une commande d'ecran (on/off).
pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
if cfg!(target_os = "windows") {
windows::execute_screen(backend, value)
} else {
linux::execute_screen(backend, value)
}
}
+27
View File
@@ -0,0 +1,27 @@
// Implementations Windows (winapi_session ou external_tool).
use anyhow::{bail, Result};
use tracing::info;
use crate::commands::{CommandAction, CommandValue};
// Stub Windows pour les commandes power (a completer).
pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
match backend {
"windows_service" | "winapi_session" | "external_tool" => {
info!(?action, "windows power backend stub");
Ok(())
}
_ => bail!("unknown windows power backend"),
}
}
// Stub Windows pour l'ecran (a completer).
pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
match backend {
"winapi_session" | "external_tool" => {
info!(?value, "windows screen backend stub");
Ok(())
}
_ => bail!("unknown windows screen backend"),
}
}
+332
View File
@@ -0,0 +1,332 @@
// Ce module orchestre le cycle de vie de l'application.
use anyhow::Result;
use std::time::Instant;
use std::collections::HashMap;
use std::process::Command;
use tokio::time::{interval, sleep, Duration};
use tracing::warn;
use crate::config::Config;
use crate::commands::{self, CommandAction, CommandValue};
use crate::ha;
use crate::mqtt::{self, Backends, Capabilities, Status};
use crate::platform;
use crate::telemetry::{BasicTelemetry, TelemetryProvider};
pub struct Runtime {
config: Config,
start: Instant,
}
impl Runtime {
// Cree un runtime avec la configuration chargee.
pub fn new(config: Config) -> Self {
Self {
config,
start: Instant::now(),
}
}
// Demarre la connexion MQTT et boucle sur l'eventloop.
pub async fn run(self) -> Result<()> {
let handle = mqtt::connect(&self.config)?;
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;
}
}
}
// Spawn event loop handler in background to process messages
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
match event_loop.poll().await {
Ok(rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish))) => {
let _ = cmd_tx.send((publish.topic.to_string(), publish.payload.to_vec()));
}
Ok(_) => {}
Err(err) => {
tracing::warn!(error = %err, "mqtt eventloop error");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
});
// Send initial messages
if self.config.publish.availability {
mqtt::publish_availability(&client, &self.config, true).await?;
}
let status = build_status(&self.config, self.start.elapsed().as_secs());
mqtt::publish_status(&client, &self.config, &status).await?;
mqtt::publish_capabilities(&client, &self.config, &capabilities(&self.config)).await?;
if let Err(err) = ha::publish_all(&client, &self.config).await {
warn!(error = %err, "ha discovery publish failed");
}
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?;
}
tracing::info!("entering main event loop");
let mut telemetry = if self.config.features.telemetry.enabled {
Some(BasicTelemetry::new())
} else {
None
};
let mut telemetry_tick = interval(Duration::from_secs(
self.config.features.telemetry.interval_s,
));
let mut heartbeat_tick = interval(Duration::from_secs(
self.config.publish.heartbeat_s,
));
let mut last_exec: HashMap<CommandAction, std::time::Instant> = HashMap::new();
let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown);
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");
}
}
}
_ = 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");
}
}
Some((topic, payload)) = cmd_rx.recv() => {
if let Err(err) = handle_command(
&client,
&self.config,
&mut last_exec,
&topic,
&payload,
).await {
warn!(error = %err, "command handling failed");
}
}
_ = &mut shutdown => {
if let Err(err) = mqtt::publish_availability(&client, &self.config, false).await {
warn!(error = %err, "publish availability offline failed");
}
if let Err(err) = client.disconnect().await {
warn!(error = %err, "mqtt disconnect failed");
}
break Ok(());
}
}
}
}
}
// 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());
}
let mut commands = Vec::new();
if cfg.features.commands.enabled {
commands.push("shutdown".to_string());
commands.push("reboot".to_string());
commands.push("sleep".to_string());
commands.push("screen".to_string());
}
Capabilities {
telemetry,
commands,
gpu: false,
}
}
// Retourne le backend power actif selon l'OS.
fn backend_power(cfg: &Config) -> String {
if cfg!(target_os = "windows") {
cfg.power_backend.windows.clone()
} else {
cfg.power_backend.linux.clone()
}
}
// Retourne le backend screen actif selon l'OS.
fn backend_screen(cfg: &Config) -> String {
if cfg!(target_os = "windows") {
cfg.screen_backend.windows.clone()
} else {
cfg.screen_backend.linux.clone()
}
}
// Construit un status stable (version, OS, uptime, backends).
fn build_status(cfg: &Config, uptime_s: u64) -> Status {
Status {
version: "2.0.0".to_string(),
os: std::env::consts::OS.to_string(),
uptime_s,
last_error: String::new(),
backends: Backends {
power: backend_power(cfg),
screen: backend_screen(cfg),
},
}
}
// Essaie de determiner l'etat d'alimentation sur Linux via systemctl.
fn detect_power_state() -> String {
if cfg!(target_os = "windows") {
return "on".to_string();
}
if let Some(state) = detect_power_state_logind() {
return state;
}
match Command::new("systemctl").arg("is-system-running").output() {
Ok(output) => {
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
match raw.as_str() {
"running" | "degraded" => "on".to_string(),
"stopping" | "starting" => "unknown".to_string(),
_ => "unknown".to_string(),
}
}
Err(_) => "unknown".to_string(),
}
}
// Essaie de lire l'etat logind (Active + IdleHint).
fn detect_power_state_logind() -> Option<String> {
if let Ok(connection) = zbus::blocking::Connection::system() {
if let Ok(proxy) = zbus::blocking::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
) {
let active: Result<bool, _> = proxy.get_property("IdleHint").map(|v| v);
if let Ok(idle_hint) = active {
if idle_hint {
return Some("idle".to_string());
}
return Some("on".to_string());
}
}
}
None
}
// Traite une commande entrante (topic + payload) avec cooldown et dry-run.
async fn handle_command(
client: &rumqttc::AsyncClient,
cfg: &Config,
last_exec: &mut HashMap<CommandAction, std::time::Instant>,
topic: &str,
payload: &[u8],
) -> anyhow::Result<()> {
let action = commands::parse_action(topic)?;
let value = commands::parse_value(payload)?;
if !commands::allowlist_allows(&cfg.features.commands.allowlist, action) {
return Ok(());
}
if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action) {
return Ok(());
}
if cfg.features.commands.dry_run {
commands::execute_dry_run(action, value)?;
publish_command_state(client, cfg, action, value).await?;
return Ok(());
}
match action {
CommandAction::Shutdown => {
if matches!(value, CommandValue::Off) {
platform::execute_power(&backend_power(cfg), action)?;
mqtt::publish_state(client, cfg, "power_state", "off").await?;
publish_command_state(client, cfg, action, value).await?;
}
}
CommandAction::Reboot => {
if matches!(value, CommandValue::Off) {
platform::execute_power(&backend_power(cfg), action)?;
mqtt::publish_state(client, cfg, "power_state", "on").await?;
publish_command_state(client, cfg, action, value).await?;
}
}
CommandAction::Sleep => {
if matches!(value, CommandValue::Off) {
platform::execute_power(&backend_power(cfg), action)?;
mqtt::publish_state(client, cfg, "power_state", "sleep").await?;
publish_command_state(client, cfg, action, value).await?;
}
}
CommandAction::Screen => {
platform::execute_screen(&backend_screen(cfg), value)?;
publish_command_state(client, cfg, action, value).await?;
}
}
Ok(())
}
// Publie l'etat initial des switches HA (par defaut ON).
async fn publish_initial_command_states(client: &rumqttc::AsyncClient, cfg: &Config) {
let _ = mqtt::publish_state(client, cfg, "shutdown", "ON").await;
let _ = mqtt::publish_state(client, cfg, "reboot", "ON").await;
let _ = mqtt::publish_state(client, cfg, "sleep", "ON").await;
let _ = mqtt::publish_state(client, cfg, "screen", "ON").await;
}
// Publie l'etat d'une commande pour Home Assistant.
async fn publish_command_state(
client: &rumqttc::AsyncClient,
cfg: &Config,
action: CommandAction,
value: CommandValue,
) -> anyhow::Result<()> {
let state = match value {
CommandValue::On => "ON",
CommandValue::Off => "OFF",
};
let name = commands::action_name(action);
mqtt::publish_state(client, cfg, name, state).await
}
+1
View File
@@ -0,0 +1 @@
// Fonctions de securite futures (HMAC, signatures, ACL).
+132
View File
@@ -0,0 +1,132 @@
// Ce module declare l'interface de telemetrie et une implementation basique.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use local_ip_address::local_ip;
use sysinfo::System;
// Retourne un dictionnaire simple {nom -> valeur} pour les capteurs.
pub trait TelemetryProvider {
fn read(&mut self) -> HashMap<String, String>;
}
// Telemetrie basique cross-platform (cpu, memoire, ip, batterie).
pub struct BasicTelemetry {
system: System,
}
impl BasicTelemetry {
// Initialise le collecteur systeme.
pub fn new() -> Self {
let mut system = System::new();
system.refresh_all();
Self { system }
}
}
impl TelemetryProvider for BasicTelemetry {
fn read(&mut self) -> HashMap<String, String> {
self.system.refresh_cpu();
self.system.refresh_memory();
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());
}
// 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);
}
values
}
}
#[derive(Debug)]
struct BatteryInfo {
level: u8, // 0-100
state: String, // "charging", "discharging", "full", "unknown"
}
// Lit les informations de batterie depuis /sys/class/power_supply (Linux)
fn read_battery_info() -> Option<BatteryInfo> {
#[cfg(target_os = "linux")]
{
read_battery_linux()
}
#[cfg(target_os = "windows")]
{
read_battery_windows()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
None
}
}
#[cfg(target_os = "linux")]
fn read_battery_linux() -> Option<BatteryInfo> {
let power_supply_path = Path::new("/sys/class/power_supply");
if !power_supply_path.exists() {
return None;
}
// Find first battery device (BAT0, BAT1, or battery)
let battery_dirs = fs::read_dir(power_supply_path).ok()?;
for entry in battery_dirs.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("BAT") || name_str == "battery" {
let bat_path = entry.path();
// Read capacity (0-100)
let capacity = fs::read_to_string(bat_path.join("capacity"))
.ok()?
.trim()
.parse::<u8>()
.ok()?;
// Read status
let status = fs::read_to_string(bat_path.join("status"))
.ok()?
.trim()
.to_lowercase();
let state = match status.as_str() {
"charging" => "charging".to_string(),
"discharging" => "discharging".to_string(),
"full" => "full".to_string(),
"not charging" => "not_charging".to_string(),
_ => "unknown".to_string(),
};
return Some(BatteryInfo {
level: capacity,
state,
});
}
}
None
}
#[cfg(target_os = "windows")]
fn read_battery_windows() -> Option<BatteryInfo> {
// TODO: Implement Windows battery reading via GetSystemPowerStatus
// For now, return None
None
}