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>
333 lines
12 KiB
Rust
333 lines
12 KiB
Rust
// 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", ¤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");
|
|
}
|
|
}
|
|
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
|
|
}
|