Files
pilot/pilot-v2/src/runtime/mod.rs
Gilles Soulier c5381b7112 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>
2025-12-30 06:23:00 +01:00

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", &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
}