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:
@@ -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", ¤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
|
||||
}
|
||||
Reference in New Issue
Block a user