feat(yoga14): remote control, app management, install script

- Add keycode module: G7BTS Rii remote control support (evdev, auto-reconnect)
- Add key bindings: single/double press detection with configurable window
  - KEY_HOMEPAGE: single=VacuumTube, double=LiveboxTV
  - KEY_OK: inject Enter keypress via ydotool
  - KEY_PAGEUP/DOWN: LiveboxTV channel navigation
- Add M3U parser and channel selector for LiveboxTV (51 channels)
- Add volume entity (wpctl/PipeWire, 2s polling)
- Add app management: vacuum_tube, livebox_tv (start/stop/state via MQTT)
- Add grace period to prevent app state bounce after stop
- Fix screen ON via GNOME busctl: add SimulateUserActivity
- Fix power commands: trigger on ON, publish OFF immediately (momentary buttons)
- Disable GPU temp/usage entities
- Add install script: build, deploy to ~/pilot, systemd user service
- Fix service startup: WantedBy=graphical-session.target (full GNOME env at launch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 17:16:12 +01:00
parent 6c4c6ee866
commit ffabf65b35
17 changed files with 2226 additions and 252 deletions

View File

@@ -1,28 +1,25 @@
# Codex created 2025-12-29_0224
# Configuration Pilot v2 - yoga14 (Lenovo Yoga)
# Hostname auto-detecte: yoga14
device:
name: $hostname
identifiers: ["$hostname"]
manufacturer: "Asus"
model: "Laptop"
manufacturer: "Lenovo"
model: "Yoga 14"
sw_version: "2.0.0"
suggested_area: "Bureau"
mqtt:
host: "10.0.0.3"
host: "10.0.0.3" # <- adresse de ton serveur Home Assistant / broker Mosquitto
port: 1883
username: ""
username: "" # <- si authentification activee sur Mosquitto
password: ""
base_topic: "pilot"
discovery_prefix: "homeassistant"
client_id: "$hostname"
keepalive_s: 60
qos: 0
qos: 1
retain_states: true
reconnect:
attempts: 3
retry_delay_s: 1
short_wait_s: 60
long_wait_s: 3600
features:
telemetry:
@@ -38,33 +35,13 @@ features:
device_class: ""
icon: "mdi:chip"
state_class: "measurement"
pilot_v2_cpu_usage:
enabled: true
discovery_enabled: true
interval_s: 10
name: "Pilot V2 CPU Usage"
unique_id: "$hostname_pilot_v2_cpu_usage"
unit: "%"
device_class: ""
icon: "mdi:apps"
state_class: "measurement"
pilot_v2_mem_used_mb:
enabled: true
discovery_enabled: true
interval_s: 10
name: "Pilot V2 Memory Used"
unique_id: "$hostname_pilot_v2_mem_used_mb"
unit: "MB"
device_class: ""
icon: "mdi:memory"
state_class: "measurement"
cpu_temp_c:
enabled: true
discovery_enabled: true
interval_s: 30
interval_s: 10
name: "CPU Temp"
unique_id: "$hostname_cpu_temp"
unit: "°C"
unit: "C"
device_class: "temperature"
icon: "mdi:thermometer"
state_class: "measurement"
@@ -74,114 +51,35 @@ features:
interval_s: 60
name: "SSD Temp"
unique_id: "$hostname_ssd_temp"
unit: "°C"
unit: "C"
device_class: "temperature"
icon: "mdi:thermometer"
state_class: "measurement"
gpu_usage:
enabled: true
discovery_enabled: true
# GPU integre AMD (desactive - donnees non fiables sur ce modele)
amd_gpu_usage:
enabled: false
discovery_enabled: false
interval_s: 10
name: "GPU Usage"
unique_id: "$hostname_gpu_usage"
unit: "%"
device_class: ""
icon: "mdi:expansion-card"
state_class: "measurement"
gpu0_usage:
enabled: true
discovery_enabled: true
interval_s: 10
name: "GPU0 Usage"
unique_id: "$hostname_gpu0_usage"
unit: "%"
device_class: ""
icon: "mdi:expansion-card"
state_class: "measurement"
gpu1_usage:
enabled: true
discovery_enabled: true
interval_s: 10
name: "GPU1 Usage"
unique_id: "$hostname_gpu1_usage"
unit: "%"
device_class: ""
icon: "mdi:expansion-card"
state_class: "measurement"
gpu0_temp_c:
enabled: true
discovery_enabled: true
interval_s: 30
name: "GPU0 Temp"
unique_id: "$hostname_gpu0_temp"
unit: "°C"
device_class: "temperature"
icon: "mdi:thermometer"
state_class: "measurement"
gpu1_temp_c:
enabled: true
discovery_enabled: true
interval_s: 30
name: "GPU1 Temp"
unique_id: "$hostname_gpu1_temp"
unit: "°C"
device_class: "temperature"
icon: "mdi:thermometer"
state_class: "measurement"
gpu0_mem_used_gb:
enabled: true
discovery_enabled: true
interval_s: 10
name: "GPU0 Memory Used"
unique_id: "$hostname_gpu0_mem_used"
unit: "GB"
device_class: ""
icon: "mdi:memory"
state_class: "measurement"
gpu1_mem_used_gb:
enabled: true
discovery_enabled: true
interval_s: 10
name: "GPU1 Memory Used"
unique_id: "$hostname_gpu1_mem_used"
unit: "GB"
device_class: ""
icon: "mdi:memory"
state_class: "measurement"
amd_gpu_usage:
enabled: true
discovery_enabled: true
interval_s: 10
name: "AMD GPU Usage"
unique_id: "$hostname_amd_gpu_usage"
unit: "%"
device_class: ""
icon: "mdi:expansion-card"
icon: "mdi:gpu"
state_class: "measurement"
amd_gpu_temp_c:
enabled: true
discovery_enabled: true
interval_s: 30
name: "AMD GPU Temp"
enabled: false
discovery_enabled: false
interval_s: 10
name: "GPU Temp"
unique_id: "$hostname_amd_gpu_temp"
unit: "°C"
unit: "C"
device_class: "temperature"
icon: "mdi:thermometer"
state_class: "measurement"
amd_gpu_mem_used_gb:
enabled: true
discovery_enabled: true
interval_s: 10
name: "AMD GPU Memory Used"
unique_id: "$hostname_amd_gpu_mem_used"
unit: "GB"
device_class: ""
icon: "mdi:memory"
state_class: "measurement"
memory_used_gb:
enabled: true
discovery_enabled: true
interval_s: 20
interval_s: 10
name: "Memory Used"
unique_id: "$hostname_memory_used"
unit: "GB"
@@ -191,7 +89,7 @@ features:
memory_total_gb:
enabled: true
discovery_enabled: true
interval_s: 240
interval_s: 3600
name: "Memory Total"
unique_id: "$hostname_memory_total"
unit: "GB"
@@ -201,47 +99,27 @@ features:
disk_free_gb:
enabled: true
discovery_enabled: true
interval_s: 240
interval_s: 120
name: "Disk Free"
unique_id: "$hostname_disk_free"
unit: "GB"
device_class: ""
icon: "mdi:harddisk"
state_class: "measurement"
fan_cpu_rpm:
enabled: true
discovery_enabled: true
interval_s: 23
name: "CPU Fan"
unique_id: "$hostname_fan_cpu"
unit: "RPM"
device_class: ""
icon: "mdi:fan"
state_class: "measurement"
fan_gpu_rpm:
enabled: true
discovery_enabled: true
interval_s: 23
name: "GPU Fan"
unique_id: "$hostname_fan_gpu"
unit: "RPM"
device_class: ""
icon: "mdi:fan"
state_class: "measurement"
ip_address:
enabled: true
discovery_enabled: true
interval_s: 1200
interval_s: 120
name: "IP Address"
unique_id: "$hostname_ip"
unit: ""
device_class: ""
icon: "mdi:ip-network"
icon: "mdi:ip"
state_class: ""
battery_level:
enabled: true
discovery_enabled: true
interval_s: 240
interval_s: 60
name: "Battery Level"
unique_id: "$hostname_battery_level"
unit: "%"
@@ -251,7 +129,7 @@ features:
battery_state:
enabled: true
discovery_enabled: true
interval_s: 240
interval_s: 60
name: "Battery State"
unique_id: "$hostname_battery_state"
unit: ""
@@ -261,7 +139,7 @@ features:
power_state:
enabled: true
discovery_enabled: true
interval_s: 240
interval_s: 60
name: "Power State"
unique_id: "$hostname_power_state"
unit: ""
@@ -271,7 +149,7 @@ features:
kernel:
enabled: true
discovery_enabled: true
interval_s: 14400
interval_s: 7200
name: "Kernel"
unique_id: "$hostname_kernel"
unit: ""
@@ -281,32 +159,103 @@ features:
os_version:
enabled: true
discovery_enabled: true
interval_s: 14400
interval_s: 7200
name: "OS Version"
unique_id: "$hostname_os_version"
unit: ""
device_class: ""
icon: "mdi:monitor"
icon: "mdi:desktop-classic"
state_class: ""
volume_level:
enabled: true
discovery_enabled: true
interval_s: 30
name: "Volume Level"
unique_id: "$hostname_volume_level"
unit: "%"
device_class: ""
icon: "mdi:volume-high"
state_class: "measurement"
commands:
enabled: true
cooldown_s: 5
dry_run: false # true = simule les commandes sans les executer
allowlist: ["shutdown", "reboot", "sleep", "screen"]
dry_run: false
allowlist:
- "shutdown"
- "reboot"
- "sleep"
- "hibernate"
- "screen"
- "volume"
- "system_update"
- "inhibit_sleep"
- "app_vacuum_tube"
- "app_livebox_tv"
- "bluetooth_k3pro"
- "bluetooth_g7bts"
- "livebox_tv_channel"
power_backend:
linux: "linux_logind_polkit" # or linux_sudoers
linux: "linux_logind_polkit"
windows: "windows_service"
screen_backend:
linux: "x11_xset" #"gnome_busctl" # or "x11_xset"
windows: "winapi_session" # or external_tool
linux: "gnome_busctl" # si pas GNOME: x11_xset
windows: "winapi_session"
publish:
heartbeat_s: 30
availability: true
apps:
- name: "vacuum_tube"
display_name: "VacuumTube"
enabled: true
start_cmd: "flatpak"
start_args: ["run", "--device=dri", "rocks.shy.VacuumTube"]
process_check: "vacuumtube"
- name: "livebox_tv"
display_name: "Livebox TV"
enabled: true
start_cmd: "vlc"
start_args:
- "--fullscreen"
- "--network-caching=1000"
- "../iptv/france_tv.m3u"
process_check: "vlc"
channels_m3u: "../iptv/france_tv.m3u"
channel_next_key: "KEY_PAGEUP"
channel_prev_key: "KEY_PAGEDOWN"
bluetooth:
enabled: true
devices:
- name: "k3pro"
mac: "F1:B7:7F:BC:7B:00"
display_name: "ThinkPlus K3 Pro"
- name: "g7bts"
mac: "AA:23:02:16:32:6F"
display_name: "Rii G7BTS"
paths:
linux_config: "/etc/pilot/config.yaml"
windows_config: "C:\\ProgramData\\Pilot\\config.yaml"
# Codex modified 2025-12-29_0224
# Lecture des touches clavier/telecommande via evdev
# Necessite: utilisateur dans le groupe 'input' (sudo usermod -aG input $USER)
# Pour trouver le device: ls -la /dev/input/by-id/ apres connexion de la telecommande
keycodes:
enabled: true
devices:
- "G7BTS Keyboard" # nom tel qu'il apparait dans /sys/class/input/*/device/name
# Liaisons touches → actions (simple / double appui)
key_bindings:
enabled: true
bindings:
- key: "KEY_HOMEPAGE"
single_press: "vacuum_tube" # appui simple → toggle VacuumTube
double_press: "livebox_tv" # double appui → toggle LiveboxTV
double_press_ms: 400 # fenetre de detection en ms
- key: "KEY_OK"
single_press: "key:28" # touche OK → injecte Enter (code 28) via ydotool

View File

@@ -3,11 +3,12 @@ use anyhow::{bail, Result};
use std::time::{Duration, Instant};
use tracing::info;
// Actions d'alimentation supportees (shutdown/reboot/sleep).
// Actions d'alimentation supportees (shutdown/reboot/sleep/hibernate).
pub trait PowerControl {
fn shutdown(&self) -> Result<()>;
fn reboot(&self) -> Result<()>;
fn sleep(&self) -> Result<()>;
fn hibernate(&self) -> Result<()>;
}
// Actions d'ecran supportees (on/off).
@@ -16,18 +17,29 @@ pub trait ScreenControl {
fn screen_off(&self) -> Result<()>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CommandAction {
Shutdown,
Reboot,
Sleep,
Hibernate,
Screen,
Volume,
App(String),
Bluetooth(String),
SystemUpdate,
InhibitSleep,
/// Selecteur de chaine TV pour une app (ex: TvChannel("livebox_tv")).
TvChannel(String),
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub enum CommandValue {
On,
Off,
Number(u8),
/// Valeur texte libre (ex: nom de chaine pour select HA).
Text(String),
}
// Decode une action depuis le topic cmd/<action>/set.
@@ -41,28 +53,65 @@ pub fn parse_action(topic: &str) -> Result<CommandAction> {
"shutdown" => Ok(CommandAction::Shutdown),
"reboot" => Ok(CommandAction::Reboot),
"sleep" => Ok(CommandAction::Sleep),
"hibernate" => Ok(CommandAction::Hibernate),
"screen" => Ok(CommandAction::Screen),
"volume" => Ok(CommandAction::Volume),
"system_update" => Ok(CommandAction::SystemUpdate),
"inhibit_sleep" => Ok(CommandAction::InhibitSleep),
other if other.ends_with("_channel") => {
let app = other.trim_end_matches("_channel");
Ok(CommandAction::TvChannel(app.to_string()))
}
other if other.starts_with("app_") => {
Ok(CommandAction::App(other.trim_start_matches("app_").to_string()))
}
other if other.starts_with("bluetooth_") => {
Ok(CommandAction::Bluetooth(other.trim_start_matches("bluetooth_").to_string()))
}
_ => bail!("unknown action"),
}
}
// Decode une valeur ON/OFF (insensible a la casse).
// Decode une valeur ON/OFF, numerique (0-100) ou texte libre (ex: nom de chaine).
pub fn parse_value(payload: &[u8]) -> Result<CommandValue> {
let raw = String::from_utf8_lossy(payload).trim().to_uppercase();
match raw.as_str() {
let raw = String::from_utf8_lossy(payload).trim().to_string();
match raw.to_uppercase().as_str() {
"ON" => Ok(CommandValue::On),
"OFF" => Ok(CommandValue::Off),
_ => bail!("invalid payload"),
_ => {
if let Ok(n) = raw.parse::<u8>() {
Ok(CommandValue::Number(n))
} else {
Ok(CommandValue::Text(raw))
}
}
}
}
// Convertit une action en nom utilise par la config et les topics MQTT.
pub fn action_name(action: &CommandAction) -> String {
match action {
CommandAction::Shutdown => "shutdown".to_string(),
CommandAction::Reboot => "reboot".to_string(),
CommandAction::Sleep => "sleep".to_string(),
CommandAction::Hibernate => "hibernate".to_string(),
CommandAction::Screen => "screen".to_string(),
CommandAction::Volume => "volume".to_string(),
CommandAction::SystemUpdate => "system_update".to_string(),
CommandAction::InhibitSleep => "inhibit_sleep".to_string(),
CommandAction::App(name) => format!("app_{}", name),
CommandAction::Bluetooth(name) => format!("bluetooth_{}", name),
CommandAction::TvChannel(name) => format!("{}_channel", name),
}
}
// Verifie si l'action est autorisee par l'allowlist (vide = tout autoriser).
pub fn allowlist_allows(allowlist: &[String], action: CommandAction) -> bool {
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)
allowlist.iter().any(|item| item == &name)
}
// Verifie le cooldown et renvoie true si l'action est autorisee.
@@ -82,21 +131,11 @@ pub fn allow_command(
}
// Execute une commande en mode dry-run (journalise seulement).
pub fn execute_dry_run(action: CommandAction, value: CommandValue) -> Result<()> {
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::*;
@@ -107,17 +146,36 @@ mod tests {
assert_eq!(parse_action(topic).unwrap(), CommandAction::Shutdown);
}
#[test]
fn parse_action_hibernate() {
let topic = "pilot/device/cmd/hibernate/set";
assert_eq!(parse_action(topic).unwrap(), CommandAction::Hibernate);
}
#[test]
fn parse_action_app() {
let topic = "pilot/device/cmd/app_vacuum_tube/set";
assert_eq!(parse_action(topic).unwrap(), CommandAction::App("vacuum_tube".to_string()));
}
#[test]
fn parse_action_bluetooth() {
let topic = "pilot/device/cmd/bluetooth_k3pro/set";
assert_eq!(parse_action(topic).unwrap(), CommandAction::Bluetooth("k3pro".to_string()));
}
#[test]
fn parse_value_ok() {
assert!(matches!(parse_value(b"ON").unwrap(), CommandValue::On));
assert!(matches!(parse_value(b"off").unwrap(), CommandValue::Off));
assert!(matches!(parse_value(b"75").unwrap(), CommandValue::Number(75)));
}
#[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));
assert!(allowlist_allows(&list, &CommandAction::Shutdown));
assert!(!allowlist_allows(&list, &CommandAction::Reboot));
}
#[test]

View File

@@ -15,6 +15,14 @@ pub struct Config {
pub screen_backend: ScreenBackend,
pub publish: Publish,
pub paths: Option<Paths>,
#[serde(default)]
pub apps: Vec<AppConfig>,
#[serde(default)]
pub bluetooth: BluetoothSettings,
#[serde(default)]
pub keycodes: KeycodeConfig,
#[serde(default)]
pub key_bindings: KeyBindingsConfig,
}
#[derive(Debug, Clone, Deserialize)]
@@ -547,6 +555,87 @@ pub struct Paths {
pub windows_config: String,
}
// Configuration d'une application pilotable (start/stop depuis HA).
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub name: String,
pub display_name: String,
#[serde(default = "default_true")]
pub enabled: bool,
pub start_cmd: String,
#[serde(default)]
pub start_args: Vec<String>,
pub process_check: String,
/// Chemin vers un fichier M3U pour activer le selecteur de chaine (optionnel).
#[serde(default)]
pub channels_m3u: Option<String>,
/// Touche (nom keycode) pour passer a la chaine suivante.
#[serde(default)]
pub channel_next_key: Option<String>,
/// Touche (nom keycode) pour passer a la chaine precedente.
#[serde(default)]
pub channel_prev_key: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_double_press_ms() -> u64 {
400
}
// Liaison touche → action (simple / double appui).
#[derive(Debug, Clone, Deserialize)]
pub struct KeyBinding {
/// Nom du keycode tel que publie par le module keycode (ex: "KEY_HOME").
pub key: String,
/// Action sur appui simple : nom d'une app (ex: "vacuum_tube") ou commande.
#[serde(default)]
pub single_press: Option<String>,
/// Action sur double appui.
#[serde(default)]
pub double_press: Option<String>,
/// Fenetre de detection du double appui en millisecondes.
#[serde(default = "default_double_press_ms")]
pub double_press_ms: u64,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct KeyBindingsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub bindings: Vec<KeyBinding>,
}
// Configuration du lecteur de touches (telecommande/clavier evdev).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct KeycodeConfig {
/// Active l'ecoute des evenements clavier.
#[serde(default)]
pub enabled: bool,
/// Chemins des devices a surveiller (ex: /dev/input/by-id/...).
#[serde(default)]
pub devices: Vec<String>,
}
// Configuration Bluetooth (appareils a surveiller/controler).
#[derive(Debug, Clone, Deserialize)]
pub struct BluetoothDevice {
pub name: String,
pub mac: String,
pub display_name: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct BluetoothSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub devices: Vec<BluetoothDevice>,
}
// Charge la config depuis les chemins par defaut (OS + fallback).
pub fn load() -> Result<Config> {
let candidates = candidate_paths();
@@ -767,8 +856,8 @@ publish:
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)));
assert_eq!(metric.unique_id.as_deref(), Some(format!("{}_cpu_usage", hostname).as_str()));
assert_eq!(metric.name.as_deref(), Some(format!("{} CPU Usage", hostname).as_str()));
let hostname = get_hostname().unwrap();
assert_eq!(cfg.device.name, hostname);
assert_eq!(cfg.device.identifiers[0], hostname);

View File

@@ -18,6 +18,7 @@ struct DeviceInfo {
suggested_area: Option<String>,
}
// Entite generique (sensor, switch).
#[derive(Serialize)]
struct EntityConfig {
name: String,
@@ -43,6 +44,42 @@ struct EntityConfig {
icon: Option<String>,
}
// Entite select HA (selecteur de chaine TV).
#[derive(Serialize)]
struct SelectEntityConfig {
name: String,
unique_id: String,
state_topic: String,
command_topic: String,
availability_topic: String,
payload_available: String,
payload_not_available: String,
options: Vec<String>,
device: DeviceInfo,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
}
// Entite number HA (slider volume 0-100).
#[derive(Serialize)]
struct NumberEntityConfig {
name: String,
unique_id: String,
state_topic: String,
command_topic: String,
availability_topic: String,
payload_available: String,
payload_not_available: String,
device: DeviceInfo,
min: f32,
max: f32,
step: f32,
#[serde(skip_serializing_if = "Option::is_none")]
unit_of_measurement: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
}
// 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);
@@ -91,32 +128,181 @@ pub async fn publish_all(client: &AsyncClient, cfg: &Config) -> Result<()> {
}
}
let switches = vec![
("shutdown", "Shutdown", "cmd/shutdown/set"),
("reboot", "Reboot", "cmd/reboot/set"),
("sleep", "Sleep", "cmd/sleep/set"),
("screen", "Screen", "cmd/screen/set"),
];
if cfg.features.commands.enabled {
// Switches power/screen standard
let switches = vec![
("shutdown", "Shutdown", "cmd/shutdown/set", "mdi:power"),
("reboot", "Reboot", "cmd/reboot/set", "mdi:restart"),
("sleep", "Sleep", "cmd/sleep/set", "mdi:sleep"),
("hibernate", "Hibernate", "cmd/hibernate/set", "mdi:snowflake"),
("screen", "Screen", "cmd/screen/set", "mdi:monitor"),
("system_update", "System Update", "cmd/system_update/set", "mdi:update"),
("inhibit_sleep", "Inhibit Sleep", "cmd/inhibit_sleep/set", "mdi:sleep-off"),
];
for (key, _name, cmd) in switches {
let entity_name = format!("{}_{}", key, cfg.device.name);
for (key, label, cmd, icon) in switches {
// Ignore hibernate/system_update si pas dans l'allowlist
if !cfg.features.commands.allowlist.is_empty()
&& !cfg.features.commands.allowlist.iter().any(|a| a == key)
{
continue;
}
let entity_name = format!("{}_{}", key, cfg.device.name);
let entity = EntityConfig {
name: format!("{} {}", label, cfg.device.name),
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/{}/state", base, key),
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".to_string()),
payload_off: Some("OFF".to_string()),
unit_of_measurement: None,
device_class: Some("switch".to_string()),
state_class: None,
icon: Some(icon.to_string()),
};
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
}
// Entite number pour le volume
if cfg.features.commands.allowlist.is_empty()
|| cfg.features.commands.allowlist.iter().any(|a| a == "volume")
{
let volume_entity = NumberEntityConfig {
name: format!("Volume {}", cfg.device.name),
unique_id: format!("{}_volume", cfg.device.name),
state_topic: format!("{}/volume/state", base),
command_topic: format!("{}/cmd/volume/set", base),
availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(),
payload_not_available: "offline".to_string(),
device: DeviceInfo { ..device.clone() },
min: 0.0,
max: 100.0,
step: 1.0,
unit_of_measurement: Some("%".to_string()),
icon: Some("mdi:volume-high".to_string()),
};
let topic = format!(
"{}/number/{}/volume_{}/config",
prefix, cfg.device.name, cfg.device.name
);
publish_discovery(client, &topic, &volume_entity).await?;
}
// Switches pour les apps configurees
for app in &cfg.apps {
if !app.enabled {
continue;
}
let key = format!("app_{}", app.name);
if !cfg.features.commands.allowlist.is_empty()
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
{
continue;
}
let entity_name = format!("{}_{}", key, cfg.device.name);
let entity = EntityConfig {
name: format!("{} {}", app.display_name, cfg.device.name),
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/{}/state", base, key),
availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(),
payload_not_available: "offline".to_string(),
device: DeviceInfo { ..device.clone() },
command_topic: Some(format!("{}/cmd/{}/set", base, key)),
payload_on: Some("ON".to_string()),
payload_off: Some("OFF".to_string()),
unit_of_measurement: None,
device_class: Some("switch".to_string()),
state_class: None,
icon: Some("mdi:application".to_string()),
};
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
// Selecteur de chaine si channels_m3u est configure
if let Some(m3u_path) = &app.channels_m3u {
let channels = crate::m3u::parse_file(m3u_path);
if !channels.is_empty() {
let options: Vec<String> = channels.into_iter().map(|(name, _)| name).collect();
let channel_key = format!("{}_channel", app.name);
let channel_entity_name = format!("{}_{}", channel_key, cfg.device.name);
let select = SelectEntityConfig {
name: format!("{} Channel {}", app.display_name, cfg.device.name),
unique_id: format!("{}_{}", cfg.device.name, channel_key),
state_topic: format!("{}/{}/state", base, channel_key),
command_topic: format!("{}/cmd/{}/set", base, channel_key),
availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(),
payload_not_available: "offline".to_string(),
options,
device: DeviceInfo { ..device.clone() },
icon: Some("mdi:television-play".to_string()),
};
let topic = format!("{}/select/{}/{}/config", prefix, cfg.device.name, channel_entity_name);
publish_discovery(client, &topic, &select).await?;
}
}
}
// Switches pour les appareils Bluetooth
if cfg.bluetooth.enabled {
for dev in &cfg.bluetooth.devices {
let key = format!("bluetooth_{}", dev.name);
if !cfg.features.commands.allowlist.is_empty()
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
{
continue;
}
let entity_name = format!("{}_{}", key, cfg.device.name);
let entity = EntityConfig {
name: format!("{} {}", dev.display_name, cfg.device.name),
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/{}/state", base, key),
availability_topic: format!("{}/availability", base),
payload_available: "online".to_string(),
payload_not_available: "offline".to_string(),
device: DeviceInfo { ..device.clone() },
command_topic: Some(format!("{}/cmd/{}/set", base, key)),
payload_on: Some("ON".to_string()),
payload_off: Some("OFF".to_string()),
unit_of_measurement: None,
device_class: Some("switch".to_string()),
state_class: None,
icon: Some("mdi:bluetooth".to_string()),
};
let topic =
format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
}
}
}
// Capteur keycode (derniere touche pressee sur la telecommande)
if cfg.keycodes.enabled {
let entity = EntityConfig {
name: entity_name.clone(),
unique_id: format!("{}_{}", cfg.device.name, key),
state_topic: format!("{}/{}/state", base, key),
name: format!("Keycode {}", cfg.device.name),
unique_id: format!("{}_keycode", cfg.device.name),
state_topic: format!("{}/keycode", base),
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".to_string()),
payload_off: Some("OFF".to_string()),
command_topic: None,
payload_on: None,
payload_off: None,
unit_of_measurement: None,
device_class: Some("switch".to_string()),
device_class: None,
state_class: None,
icon: Some("mdi:power".to_string()),
icon: Some("mdi:remote".to_string()),
};
let topic = format!("{}/switch/{}/{}/config", prefix, cfg.device.name, entity_name);
let entity_name = format!("keycode_{}", cfg.device.name);
let topic = format!("{}/sensor/{}/{}/config", prefix, cfg.device.name, entity_name);
publish_discovery(client, &topic, &entity).await?;
}

197
pilot-v2/src/keycode.rs Normal file
View File

@@ -0,0 +1,197 @@
// Lecture des evenements clavier via evdev (lecture brute des fichiers /dev/input/event*).
// Necessite que l'utilisateur soit dans le groupe 'input'.
//
// Format input_event sur Linux 64-bit:
// [0..8] tv_sec (i64)
// [8..16] tv_usec (i64)
// [16..18] type (u16)
// [18..20] code (u16)
// [20..24] value (i32)
// Total: 24 bytes
use std::fs::File;
use std::io::Read;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{debug, info, warn};
const EV_KEY: u16 = 1;
const KEY_DOWN: i32 = 1;
const INPUT_EVENT_SIZE: usize = 24;
/// Demarre les threads de lecture.
/// Supporte les chemins directs (/dev/input/eventX) ET les noms de device ("G7BTS Keyboard").
/// Les noms sont resolus dynamiquement — gere la reconnexion Bluetooth automatiquement.
pub fn start_listener(devices: Vec<String>, tx: UnboundedSender<String>) {
for entry in devices {
let tx = tx.clone();
std::thread::spawn(move || {
if entry.starts_with('/') {
// Chemin direct
device_loop(&entry, tx);
} else {
// Nom de device: resolution dynamique
device_loop_by_name(&entry, tx);
}
});
}
}
/// Trouve le chemin /dev/input/eventX pour un device par son nom.
pub fn find_device_by_name(name: &str) -> Option<String> {
let entries = std::fs::read_dir("/sys/class/input").ok()?;
for entry in entries.flatten() {
let input_name = entry.file_name();
let input_name_str = input_name.to_string_lossy();
if !input_name_str.starts_with("event") {
continue;
}
let name_file = entry.path().join("device/name");
if let Ok(dev_name) = std::fs::read_to_string(&name_file) {
if dev_name.trim().to_lowercase().contains(&name.to_lowercase()) {
return Some(format!("/dev/input/{}", input_name_str));
}
}
}
None
}
/// Boucle par nom: resout le chemin periodiquement pour gerer les reconnexions.
fn device_loop_by_name(name: &str, tx: UnboundedSender<String>) {
loop {
match find_device_by_name(name) {
Some(path) => {
info!(device_name = %name, path = %path, "input device found");
device_loop_once(&path, &tx);
// La boucle interne est sortie — device deconnecte ou erreur
warn!(device_name = %name, "input device disconnected, waiting for reconnect...");
}
None => {
info!(device_name = %name, "keycode: input device not found, retrying in 5s");
}
}
std::thread::sleep(std::time::Duration::from_secs(5));
}
}
/// Boucle principale pour un device par chemin fixe.
fn device_loop(path: &str, tx: UnboundedSender<String>) {
let mut backoff_s = 2u64;
loop {
match File::open(path) {
Ok(mut file) => {
debug!(device = %path, "input device opened");
backoff_s = 2;
read_events(&mut file, &tx, path);
warn!(device = %path, "lost connection to input device, retrying in {}s", backoff_s);
}
Err(e) => {
warn!(device = %path, error = %e, "cannot open input device, retrying in {}s", backoff_s);
}
}
std::thread::sleep(std::time::Duration::from_secs(backoff_s));
backoff_s = (backoff_s * 2).min(60);
}
}
/// Ouvre et lit un device une fois (sort en cas d'erreur).
fn device_loop_once(path: &str, tx: &UnboundedSender<String>) {
match File::open(path) {
Ok(mut file) => {
read_events(&mut file, tx, path);
}
Err(e) => {
warn!(device = %path, error = %e, "cannot open input device");
}
}
}
/// Lit les evenements bruts depuis le fichier jusqu'a erreur.
fn read_events(file: &mut File, tx: &UnboundedSender<String>, path: &str) {
let mut buf = [0u8; INPUT_EVENT_SIZE];
loop {
if let Err(e) = file.read_exact(&mut buf) {
warn!(device = %path, error = %e, "read error on input device");
return;
}
let ev_type = u16::from_ne_bytes([buf[16], buf[17]]);
let code = u16::from_ne_bytes([buf[18], buf[19]]);
let value = i32::from_ne_bytes([buf[20], buf[21], buf[22], buf[23]]);
if ev_type == EV_KEY && value == KEY_DOWN {
let name = key_name(code);
info!(device = %path, key = %name, code = code, "keycode: key pressed");
let _ = tx.send(name);
}
}
}
/// Convertit un code Linux en nom lisible.
pub fn key_name(code: u16) -> String {
let name = match code {
1 => "KEY_ESC",
2 => "KEY_1", 3 => "KEY_2", 4 => "KEY_3", 5 => "KEY_4",
6 => "KEY_5", 7 => "KEY_6", 8 => "KEY_7", 9 => "KEY_8",
10 => "KEY_9", 11 => "KEY_0",
14 => "KEY_BACKSPACE",
15 => "KEY_TAB",
28 => "KEY_ENTER",
57 => "KEY_SPACE",
102 => "KEY_HOME",
103 => "KEY_UP",
104 => "KEY_PAGEUP",
105 => "KEY_LEFT",
106 => "KEY_RIGHT",
107 => "KEY_END",
108 => "KEY_DOWN",
109 => "KEY_PAGEDOWN",
110 => "KEY_INSERT",
111 => "KEY_DELETE",
113 => "KEY_MUTE",
114 => "KEY_VOLUMEDOWN",
115 => "KEY_VOLUMEUP",
116 => "KEY_POWER",
119 => "KEY_PAUSE",
128 => "KEY_STOP",
139 => "KEY_MENU",
142 => "KEY_SLEEP",
143 => "KEY_WAKEUP",
158 => "KEY_BACK",
159 => "KEY_FORWARD",
163 => "KEY_NEXTSONG",
164 => "KEY_PLAYPAUSE",
165 => "KEY_PREVIOUSSONG",
166 => "KEY_STOPCD",
167 => "KEY_RECORD",
168 => "KEY_REWIND",
172 => "KEY_HOMEPAGE", // touche Home de telecommande
173 => "KEY_REFRESH",
174 => "KEY_EXIT",
// Navigation chaines TV
402 => "KEY_CHANNELUP",
403 => "KEY_CHANNELDOWN",
// Touches telecommande
353 => "KEY_OK",
// Boutons gamepad/manette (BTN_*)
304 => "BTN_A",
305 => "BTN_B",
306 => "BTN_C",
307 => "BTN_X",
308 => "BTN_Y",
309 => "BTN_Z",
310 => "BTN_TL",
311 => "BTN_TR",
312 => "BTN_TL2",
313 => "BTN_TR2",
314 => "BTN_SELECT",
315 => "BTN_START",
316 => "BTN_MODE",
317 => "BTN_THUMBL",
318 => "BTN_THUMBR",
544 => "BTN_DPAD_UP",
545 => "BTN_DPAD_DOWN",
546 => "BTN_DPAD_LEFT",
547 => "BTN_DPAD_RIGHT",
_ => return format!("KEY_{}", code),
};
name.to_string()
}

View File

@@ -2,6 +2,8 @@
pub mod config;
pub mod mqtt;
pub mod ha;
pub mod keycode;
pub mod m3u;
pub mod telemetry;
pub mod commands;
pub mod platform;

60
pilot-v2/src/m3u.rs Normal file
View File

@@ -0,0 +1,60 @@
// Parseur M3U simplifie pour les playlists IPTV.
/// Retourne la liste des chaines sous forme (nom, url).
pub fn parse(content: &str) -> Vec<(String, String)> {
let mut channels = Vec::new();
let mut pending_name: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.starts_with("#EXTINF:") {
// Le nom de la chaine est apres la derniere virgule de la ligne #EXTINF
if let Some(pos) = line.rfind(',') {
let name = line[pos + 1..].trim().to_string();
if !name.is_empty() {
pending_name = Some(name);
}
}
} else if line.starts_with('#') || line.is_empty() {
// Ligne de commentaire ou vide — on ignore mais on garde pending_name
continue;
} else {
// Ligne URL
if let Some(name) = pending_name.take() {
channels.push((name, line.to_string()));
}
}
}
channels
}
/// Charge et parse un fichier M3U. Retourne une liste vide en cas d'erreur.
pub fn parse_file(path: &str) -> Vec<(String, String)> {
match std::fs::read_to_string(path) {
Ok(content) => parse(&content),
Err(_) => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic() {
let content = "#EXTM3U\n#EXTINF:-1,Arte\nhttps://arte.example.com/live.m3u8\n#EXTINF:-1,BFM TV\nhttps://bfm.example.com/live.m3u8\n";
let channels = parse(content);
assert_eq!(channels.len(), 2);
assert_eq!(channels[0].0, "Arte");
assert_eq!(channels[1].0, "BFM TV");
}
#[test]
fn parse_with_vlcopt() {
let content = "#EXTINF:-1 tvg-id=\"test\",Arte\n#EXTVLCOPT:http-user-agent=Mozilla\nhttps://arte.example.com/live.m3u8\n";
let channels = parse(content);
assert_eq!(channels.len(), 1);
assert_eq!(channels[0].0, "Arte");
assert_eq!(channels[0].1, "https://arte.example.com/live.m3u8");
}
}

View File

@@ -1,7 +1,8 @@
// Implementations Linux (logind, sudoers, gnome busctl, x11 xset).
// Implementations Linux (logind, sudoers, gnome busctl, x11 xset, audio, apps, bluetooth).
use anyhow::{bail, Context, Result};
use tracing::debug;
use std::process::Command;
use std::path::Path;
use crate::commands::{CommandAction, CommandValue};
@@ -12,13 +13,17 @@ pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
CommandAction::Shutdown => run("systemctl", &["poweroff"]),
CommandAction::Reboot => run("systemctl", &["reboot"]),
CommandAction::Sleep => run("systemctl", &["suspend"]),
CommandAction::Hibernate => run("systemctl", &["hibernate"]),
CommandAction::Screen => bail!("screen action not supported in power backend"),
_ => bail!("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::Hibernate => run("systemctl", &["hibernate"]),
CommandAction::Screen => bail!("screen action not supported in power backend"),
_ => bail!("action not supported in power backend"),
},
_ => bail!("unknown linux power backend"),
}
@@ -41,19 +46,35 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
"1",
],
),
CommandValue::On => run(
"busctl",
&[
"--user",
"set-property",
"org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig",
"org.gnome.Mutter.DisplayConfig",
"PowerSaveMode",
"i",
"0",
],
),
CommandValue::On => {
// Retirer le mode veille ecran
run(
"busctl",
&[
"--user",
"set-property",
"org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig",
"org.gnome.Mutter.DisplayConfig",
"PowerSaveMode",
"i",
"0",
],
)?;
// Simuler une activite utilisateur pour reveiller l'ecran
let _ = Command::new("busctl")
.args([
"--user",
"call",
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
"SimulateUserActivity",
])
.output();
Ok(())
}
_ => bail!("unsupported value for screen"),
},
"x11_xset" => match value {
CommandValue::Off => {
@@ -64,11 +85,212 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
log_x11_env();
run("xset", &["dpms", "force", "on"])
}
_ => bail!("unsupported value for screen"),
},
_ => bail!("unknown linux screen backend"),
}
}
// Regle le volume via wpctl (PipeWire). Volume en pourcentage 0-100.
pub fn execute_audio(volume: u8) -> Result<()> {
let level = format!("{:.2}", volume as f32 / 100.0);
debug!(volume = volume, level = %level, "setting volume via wpctl");
let mut cmd = Command::new("wpctl");
cmd.args(["set-volume", "@DEFAULT_AUDIO_SINK@", &level]);
inject_audio_env(&mut cmd);
let output = cmd.output().context("failed to run wpctl set-volume")?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("wpctl set-volume failed ({}): {}", output.status, stderr)
}
}
// Lit le volume actuel via wpctl. Retourne 0-100 ou None en cas d'erreur.
pub fn read_volume() -> Option<u8> {
let mut cmd = Command::new("wpctl");
cmd.args(["get-volume", "@DEFAULT_AUDIO_SINK@"]);
inject_audio_env(&mut cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
// Sortie format: "Volume: 0.60" ou "Volume: 0.60 [MUTED]"
let raw = String::from_utf8_lossy(&output.stdout);
let vol_str = raw.split_whitespace().nth(1)?;
let vol_f: f32 = vol_str.parse().ok()?;
Some((vol_f * 100.0).round() as u8)
}
// Injecte XDG_RUNTIME_DIR/PIPEWIRE_RUNTIME_DIR pour les commandes audio.
// Necessaire quand pilot tourne sans ces variables d'environnement (service systeme).
fn inject_audio_env(cmd: &mut Command) {
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
cmd.env("XDG_RUNTIME_DIR", &xdg);
cmd.env("PIPEWIRE_RUNTIME_DIR", &xdg);
return;
}
// Fallback: chercher /run/user/<uid> avec un socket pipewire
if let Ok(entries) = std::fs::read_dir("/run/user") {
for entry in entries.flatten() {
let dir = entry.path();
if dir.join("pipewire-0").exists() || dir.join("wayland-0").exists() {
if let Some(dir_str) = dir.to_str() {
cmd.env("XDG_RUNTIME_DIR", dir_str);
cmd.env("PIPEWIRE_RUNTIME_DIR", dir_str);
return;
}
}
}
}
}
// Demarre une application configuree en transmettant les variables d'environnement GUI.
pub fn execute_app_start(start_cmd: &str, start_args: &[String]) -> Result<()> {
debug!(cmd = %start_cmd, args = ?start_args, "starting app");
let args_ref: Vec<&str> = start_args.iter().map(|s| s.as_str()).collect();
let mut cmd = Command::new(start_cmd);
cmd.args(&args_ref);
// Transmettre les variables X11/Wayland/DBus pour les apps graphiques.
// Si non definies (ex: service systemd), construire depuis XDG_RUNTIME_DIR.
for var in &[
"DISPLAY",
"XAUTHORITY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"XDG_SESSION_TYPE",
"HOME",
] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
// XAUTHORITY: toujours chercher le fichier mutter Xwayland, meme si DISPLAY est deja
// present dans l'environnement. Le suffix est aleatoire et change a chaque session,
// donc on ne peut pas le hardcoder dans le service systemd.
if std::env::var("XAUTHORITY").is_err() {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| "/run/user/1000".to_string());
let runtime_path = Path::new(&runtime_dir);
// Fallback complet si WAYLAND_DISPLAY aussi absent
if std::env::var("WAYLAND_DISPLAY").is_err() && runtime_path.join("wayland-0").exists() {
cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
cmd.env("WAYLAND_DISPLAY", "wayland-0");
cmd.env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}/bus", runtime_dir));
}
// Injecter DISPLAY + XAUTHORITY pour XWayland (apps Electron/Flatpak)
if let Ok(auth) = find_mutter_xauth(runtime_path) {
cmd.env("DISPLAY", ":0");
cmd.env("XAUTHORITY", &auth);
debug!(xauthority = %auth.display(), "xwayland auth injected");
} else {
cmd.env("DISPLAY", ":0");
debug!("DISPLAY=:0 injected (no xauthority found)");
}
}
cmd.spawn()
.with_context(|| format!("failed to start {start_cmd}"))?;
Ok(())
}
// Arrete une application via pkill -9 (SIGKILL) pour garantir l'arret immediat.
pub fn execute_app_stop(process_check: &str) -> Result<()> {
debug!(process = %process_check, "stopping app via pkill -9");
Command::new("pkill")
.args(["-9", "-i", "-f", process_check])
.status()
.with_context(|| format!("failed to run pkill for {process_check}"))?;
Ok(())
}
// Verifie si une application est en cours d'execution via pgrep.
pub fn app_is_running(process_check: &str) -> bool {
Command::new("pgrep")
.args(["-f", process_check])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
// Connecte ou deconnecte un appareil Bluetooth via bluetoothctl.
pub fn execute_bluetooth(mac: &str, connect: bool) -> Result<()> {
let action = if connect { "connect" } else { "disconnect" };
debug!(mac = %mac, action = %action, "bluetooth command");
run("bluetoothctl", &[action, mac])
}
// Verifie si un appareil Bluetooth est connecte via bluetoothctl info.
pub fn bluetooth_is_connected(mac: &str) -> bool {
let output = Command::new("bluetoothctl")
.args(["info", mac])
.output();
match output {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.any(|line| line.trim().starts_with("Connected:") && line.contains("yes"))
}
Err(_) => false,
}
}
const SLEEP_INHIBIT_PID_FILE: &str = "/tmp/pilot-sleep-inhibit.pid";
// Active ou desactive l'inhibition de mise en veille via systemd-inhibit.
pub fn execute_inhibit_sleep(enable: bool) -> Result<()> {
if enable {
let child = Command::new("systemd-inhibit")
.args([
"--what=sleep:idle",
"--who=Pilot",
"--why=HomeAssistant inhibit",
"--mode=block",
"sleep", "infinity",
])
.spawn()
.context("failed to start systemd-inhibit")?;
let pid = child.id();
std::fs::write(SLEEP_INHIBIT_PID_FILE, pid.to_string())
.context("failed to write inhibit PID file")?;
debug!(pid = pid, "sleep inhibitor started");
} else {
if let Ok(pid_str) = std::fs::read_to_string(SLEEP_INHIBIT_PID_FILE) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
run("kill", &[&pid.to_string()])?;
}
}
let _ = std::fs::remove_file(SLEEP_INHIBIT_PID_FILE);
debug!("sleep inhibitor stopped");
}
Ok(())
}
// Verifie si l'inhibition de veille est active.
pub fn is_sleep_inhibited() -> bool {
if let Ok(pid_str) = std::fs::read_to_string(SLEEP_INHIBIT_PID_FILE) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
return Path::new(&format!("/proc/{}", pid)).exists();
}
}
false
}
// Lance la mise a jour systeme via apt (sudo apt update && apt upgrade -y).
// Necessite une entree sudoers NOPASSWD pour /usr/bin/apt.
pub fn execute_system_update() -> Result<()> {
debug!("running apt update");
run("sudo", &["apt", "update"])?;
debug!("running apt upgrade");
run("sudo", &["apt", "upgrade", "-y"])
}
fn run(cmd: &str, args: &[&str]) -> Result<()> {
debug!(%cmd, args = ?args, "running command");
let output = Command::new(cmd)
@@ -83,6 +305,20 @@ fn run(cmd: &str, args: &[&str]) -> Result<()> {
}
}
// Cherche le fichier .mutter-Xwaylandauth.* dans le runtime dir pour XWayland.
fn find_mutter_xauth(runtime_dir: &Path) -> Result<std::path::PathBuf> {
let entries = std::fs::read_dir(runtime_dir)
.context("read runtime dir")?;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(".mutter-Xwaylandauth") {
return Ok(entry.path());
}
}
anyhow::bail!("mutter Xwayland auth file not found")
}
fn log_x11_env() {
let display_env = std::env::var("DISPLAY").unwrap_or_default();
let xauth_env = std::env::var("XAUTHORITY").unwrap_or_default();

View File

@@ -7,7 +7,7 @@ use crate::commands::{CommandAction, CommandValue};
pub mod linux;
pub mod windows;
// Execute une commande d'alimentation (shutdown/reboot/sleep).
// Execute une commande d'alimentation (shutdown/reboot/sleep/hibernate).
pub fn execute_power(backend: &str, action: CommandAction) -> Result<()> {
if cfg!(target_os = "windows") {
windows::execute_power(backend, action)
@@ -24,3 +24,93 @@ pub fn execute_screen(backend: &str, value: CommandValue) -> Result<()> {
linux::execute_screen(backend, value)
}
}
// Regle le volume systeme (0-100 via wpctl).
pub fn execute_audio(volume: u8) -> Result<()> {
if cfg!(target_os = "windows") {
anyhow::bail!("audio control not implemented on Windows")
} else {
linux::execute_audio(volume)
}
}
// Lit le volume actuel (0-100).
pub fn read_volume() -> Option<u8> {
if cfg!(target_os = "windows") {
None
} else {
linux::read_volume()
}
}
// Demarre une application.
pub fn execute_app_start(start_cmd: &str, start_args: &[String]) -> Result<()> {
if cfg!(target_os = "windows") {
anyhow::bail!("app control not implemented on Windows")
} else {
linux::execute_app_start(start_cmd, start_args)
}
}
// Arrete une application via son nom de processus.
pub fn execute_app_stop(process_check: &str) -> Result<()> {
if cfg!(target_os = "windows") {
anyhow::bail!("app control not implemented on Windows")
} else {
linux::execute_app_stop(process_check)
}
}
// Verifie si une application est en cours d'execution.
pub fn app_is_running(process_check: &str) -> bool {
if cfg!(target_os = "windows") {
false
} else {
linux::app_is_running(process_check)
}
}
// Connecte ou deconnecte un appareil Bluetooth.
pub fn execute_bluetooth(mac: &str, connect: bool) -> Result<()> {
if cfg!(target_os = "windows") {
anyhow::bail!("bluetooth control not implemented on Windows")
} else {
linux::execute_bluetooth(mac, connect)
}
}
// Verifie si un appareil Bluetooth est connecte.
pub fn bluetooth_is_connected(mac: &str) -> bool {
if cfg!(target_os = "windows") {
false
} else {
linux::bluetooth_is_connected(mac)
}
}
// Active/desactive l'inhibition de veille.
pub fn execute_inhibit_sleep(enable: bool) -> Result<()> {
if cfg!(target_os = "windows") {
anyhow::bail!("sleep inhibit not implemented on Windows")
} else {
linux::execute_inhibit_sleep(enable)
}
}
// Verifie si la veille est inhibee.
pub fn is_sleep_inhibited() -> bool {
if cfg!(target_os = "windows") {
false
} else {
linux::is_sleep_inhibited()
}
}
// Lance la mise a jour systeme.
pub fn execute_system_update() -> Result<()> {
if cfg!(target_os = "windows") {
anyhow::bail!("system update not implemented on Windows")
} else {
linux::execute_system_update()
}
}

View File

@@ -116,6 +116,27 @@ impl Runtime {
mqtt::subscribe_commands(&client, &self.config).await?;
}
// Charger les chaines M3U pour les apps avec channels_m3u
let mut channels_map: HashMap<String, Vec<(String, String)>> = HashMap::new();
for app in &self.config.apps {
if let Some(m3u_path) = &app.channels_m3u {
let channels = crate::m3u::parse_file(m3u_path);
if !channels.is_empty() {
info!(app = %app.name, count = channels.len(), m3u = %m3u_path, "loaded M3U channels");
channels_map.insert(app.name.clone(), channels);
} else {
warn!(app = %app.name, m3u = %m3u_path, "M3U file empty or not found");
}
}
}
let mut current_channels: HashMap<String, String> = HashMap::new();
// Demarrer le listener de touches si configure
let (keycode_tx, mut keycode_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
if self.config.keycodes.enabled && !self.config.keycodes.devices.is_empty() {
crate::keycode::start_listener(self.config.keycodes.devices.clone(), keycode_tx);
}
tracing::info!("entering main event loop");
let mut telemetry = if self.config.features.telemetry.enabled {
@@ -128,7 +149,28 @@ impl Runtime {
self.config.publish.heartbeat_s,
));
let mut stats_tick = interval(Duration::from_secs(60));
// Ticks rapides pour volume (2s) et etat des apps (2s)
let mut volume_tick = interval(Duration::from_secs(2));
let mut app_state_tick = interval(Duration::from_secs(2));
// Sync lent (30s) : pgrep pour detecter les apps demarrees/arretees en dehors de pilot
let mut app_sync_tick = interval(Duration::from_secs(30));
let mut last_exec: HashMap<CommandAction, std::time::Instant> = HashMap::new();
// Etat interne des apps: pilot suit lui-meme l'etat ON/OFF des apps qu'il gere.
// Cela evite le probleme de race condition avec pgrep apres un stop SIGKILL.
// Un sync lent (30s) via pgrep permet de detecter les apps demarrees/arretees en dehors de pilot.
let mut app_states: HashMap<String, bool> = HashMap::new();
for app in &self.config.apps {
if app.enabled {
app_states.insert(app.name.clone(), platform::app_is_running(&app.process_check));
}
}
// Detection double appui sur les touches liees (key_bindings).
// pending_key = (nom_touche, index_binding) en attente de confirmation single/double.
let mut pending_key: Option<(String, usize)> = None;
// Timer remis a zero a chaque premier appui — expire => single press.
let key_press_timer = tokio::time::sleep(tokio::time::Duration::from_millis(u64::MAX));
tokio::pin!(key_press_timer);
let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown);
@@ -149,19 +191,33 @@ impl Runtime {
}
if !due.is_empty() {
// Metriques speciales gerees directement dans le runtime
let power_state_due = due.remove("power_state");
let volume_due = due.remove("volume_level");
// Collecte et publie les metriques standard
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", &current).await {
warn!(error = %err, "publish power_state failed");
}
}
// Volume via wpctl (capteur telemetrie)
if volume_due && enabled_metrics.contains("volume_level") {
if let Some(vol) = platform::read_volume() {
if let Err(err) = mqtt::publish_state(&client, &self.config, "volume_level", &vol.to_string()).await {
warn!(error = %err, "publish volume_level failed");
}
}
}
}
}
_ = heartbeat_tick.tick() => {
@@ -169,6 +225,28 @@ impl Runtime {
if let Err(err) = mqtt::publish_status(&client, &self.config, &status).await {
warn!(error = %err, "publish status failed");
}
// Bluetooth uniquement dans le heartbeat (moins critique)
update_bluetooth_states(&client, &self.config).await;
}
_ = volume_tick.tick() => {
// Actualisation rapide du volume (5s) pour reflechir les changements locaux
if let Some(vol) = platform::read_volume() {
let vol_str = vol.to_string();
let _ = mqtt::publish_state(&client, &self.config, "volume_level", &vol_str).await;
let _ = mqtt::publish_switch_state(&client, &self.config, "volume", &vol_str).await;
}
}
_ = app_state_tick.tick() => {
publish_app_states(&client, &self.config, &app_states).await;
}
_ = app_sync_tick.tick() => {
// Sync pgrep pour detecter les changements exterieurs
for app in &self.config.apps {
if app.enabled {
let running = platform::app_is_running(&app.process_check);
app_states.insert(app.name.clone(), running);
}
}
}
_ = stats_tick.tick() => {
let published = mqtt::take_publish_count();
@@ -181,12 +259,84 @@ impl Runtime {
&client,
&self.config,
&mut last_exec,
&channels_map,
&mut current_channels,
&mut app_states,
&topic,
&payload,
).await {
warn!(error = %err, "command handling failed");
}
}
// Timer double appui expire → single press confirme
_ = &mut key_press_timer, if pending_key.is_some() => {
if let Some((key, idx)) = pending_key.take() {
let binding = &self.config.key_bindings.bindings[idx];
if let Some(action) = &binding.single_press {
execute_key_binding_action(&client, &self.config, &mut app_states, action).await;
info!(key = %key, action = %action, "key binding: single press");
}
}
}
Some(key) = keycode_rx.recv() => {
info!(key = %key, "keycode received");
let _ = mqtt::publish_state(&client, &self.config, "keycode", &key).await;
// Detecter si cette touche est liee a un binding
let binding_idx = if self.config.key_bindings.enabled {
let idx = self.config.key_bindings.bindings.iter().position(|b| b.key == key);
info!(key = %key, found = idx.is_some(), "keycode: binding lookup");
idx
} else {
info!("keycode: key_bindings disabled");
None
};
if let Some(idx) = binding_idx {
let binding = &self.config.key_bindings.bindings[idx];
if let Some((ref pk, _)) = pending_key {
if *pk == key {
// Double appui detecte
pending_key = None;
let action = binding.double_press.clone();
info!(key = %key, action = ?action, "key binding: DOUBLE PRESS detected");
if let Some(action) = action {
execute_key_binding_action(&client, &self.config, &mut app_states, &action).await;
}
} else {
// Touche differente : fire single press de l'ancienne touche
let (old_key, old_idx) = pending_key.take().unwrap();
let old_binding = &self.config.key_bindings.bindings[old_idx];
if let Some(action) = old_binding.single_press.clone() {
info!(key = %old_key, action = %action, "key binding: single press (interrupted by other key)");
execute_key_binding_action(&client, &self.config, &mut app_states, &action).await;
}
// Armer le nouveau pending
pending_key = Some((key.clone(), idx));
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(binding.double_press_ms);
key_press_timer.as_mut().reset(deadline);
info!(key = %key, ms = binding.double_press_ms, "key binding: first press, timer armed");
}
} else {
// Premier appui : armer le timer
pending_key = Some((key.clone(), idx));
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(binding.double_press_ms);
key_press_timer.as_mut().reset(deadline);
info!(key = %key, ms = binding.double_press_ms, "key binding: first press, timer armed");
}
} else {
// Touche non liee : navigation chaines
handle_channel_nav_key(
&client,
&self.config,
&channels_map,
&mut current_channels,
&key,
).await;
}
}
_ = &mut shutdown => {
if let Err(err) = mqtt::publish_availability(&client, &self.config, false).await {
warn!(error = %err, "publish availability offline failed");
@@ -258,10 +408,42 @@ fn capabilities(cfg: &Config) -> Capabilities {
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());
let base_cmds = ["shutdown", "reboot", "sleep", "hibernate", "screen", "volume", "system_update"];
for cmd in &base_cmds {
if cfg.features.commands.allowlist.is_empty()
|| cfg.features.commands.allowlist.iter().any(|a| a == *cmd)
{
commands.push(cmd.to_string());
}
}
for app in &cfg.apps {
if app.enabled {
let key = format!("app_{}", app.name);
if cfg.features.commands.allowlist.is_empty()
|| cfg.features.commands.allowlist.iter().any(|a| a == &key)
{
commands.push(key);
}
if app.channels_m3u.is_some() {
let channel_key = format!("{}_channel", app.name);
if cfg.features.commands.allowlist.is_empty()
|| cfg.features.commands.allowlist.iter().any(|a| a == &channel_key)
{
commands.push(channel_key);
}
}
}
}
if cfg.bluetooth.enabled {
for dev in &cfg.bluetooth.devices {
let key = format!("bluetooth_{}", dev.name);
if cfg.features.commands.allowlist.is_empty()
|| cfg.features.commands.allowlist.iter().any(|a| a == &key)
{
commands.push(key);
}
}
}
}
Capabilities {
@@ -346,11 +528,15 @@ fn detect_power_state_logind() -> Option<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>,
channels_map: &HashMap<String, Vec<(String, String)>>,
current_channels: &mut HashMap<String, String>,
app_states: &mut HashMap<String, bool>,
topic: &str,
payload: &[u8],
) -> anyhow::Result<()> {
@@ -358,72 +544,368 @@ async fn handle_command(
let value = commands::parse_value(payload)?;
debug!(%topic, ?action, ?value, "command received");
if !commands::allowlist_allows(&cfg.features.commands.allowlist, action) {
if !commands::allowlist_allows(&cfg.features.commands.allowlist, &action) {
return Ok(());
}
if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action) {
if !commands::allow_command(last_exec, cfg.features.commands.cooldown_s, action.clone()) {
return Ok(());
}
if cfg.features.commands.dry_run {
commands::execute_dry_run(action, value)?;
publish_command_state(client, cfg, action, value).await?;
commands::execute_dry_run(&action, &value)?;
publish_command_state(client, cfg, &action, &value).await?;
return Ok(());
}
match action {
match &action {
CommandAction::Shutdown => {
if matches!(value, CommandValue::Off) {
platform::execute_power(&backend_power(cfg), action)?;
if matches!(value, CommandValue::On) {
// Reset immediat avant execution (la machine va s'eteindre)
mqtt::publish_switch_state(client, cfg, "shutdown", "OFF").await?;
mqtt::publish_state(client, cfg, "power_state", "off").await?;
publish_command_state(client, cfg, action, value).await?;
platform::execute_power(&backend_power(cfg), action.clone())?;
}
}
CommandAction::Reboot => {
if matches!(value, CommandValue::Off) {
platform::execute_power(&backend_power(cfg), action)?;
if matches!(value, CommandValue::On) {
mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await?;
mqtt::publish_state(client, cfg, "power_state", "on").await?;
publish_command_state(client, cfg, action, value).await?;
platform::execute_power(&backend_power(cfg), action.clone())?;
}
}
CommandAction::Sleep => {
if matches!(value, CommandValue::Off) {
platform::execute_power(&backend_power(cfg), action)?;
if matches!(value, CommandValue::On) {
mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await?;
mqtt::publish_state(client, cfg, "power_state", "sleep").await?;
publish_command_state(client, cfg, action, value).await?;
platform::execute_power(&backend_power(cfg), action.clone())?;
}
}
CommandAction::Hibernate => {
if matches!(value, CommandValue::On) {
mqtt::publish_switch_state(client, cfg, "hibernate", "OFF").await?;
mqtt::publish_state(client, cfg, "power_state", "sleep").await?;
platform::execute_power(&backend_power(cfg), action.clone())?;
}
}
CommandAction::Screen => {
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?;
platform::execute_screen(&backend, value.clone())?;
publish_command_state(client, cfg, &action, &value).await?;
}
CommandAction::Volume => {
if let CommandValue::Number(vol) = value {
platform::execute_audio(vol)?;
mqtt::publish_switch_state(client, cfg, "volume", &vol.to_string()).await?;
}
}
CommandAction::App(name) => {
let app = cfg.apps.iter().find(|a| a.name == *name && a.enabled);
if let Some(app) = app {
let key = format!("app_{}", name);
match value {
CommandValue::On => {
platform::execute_app_start(&app.start_cmd, &app.start_args)?;
app_states.insert(name.clone(), true);
mqtt::publish_switch_state(client, cfg, &key, "ON").await?;
}
CommandValue::Off => {
platform::execute_app_stop(&app.process_check)?;
app_states.insert(name.clone(), false);
mqtt::publish_switch_state(client, cfg, &key, "OFF").await?;
}
_ => {}
}
}
}
CommandAction::Bluetooth(name) => {
if cfg.bluetooth.enabled {
let dev = cfg.bluetooth.devices.iter().find(|d| d.name == *name);
if let Some(dev) = dev {
let connect = matches!(value, CommandValue::On);
platform::execute_bluetooth(&dev.mac, connect)?;
let state = if platform::bluetooth_is_connected(&dev.mac) { "ON" } else { "OFF" };
let key = format!("bluetooth_{}", name);
mqtt::publish_switch_state(client, cfg, &key, state).await?;
}
}
}
CommandAction::SystemUpdate => {
if matches!(value, CommandValue::On) {
platform::execute_system_update()?;
mqtt::publish_switch_state(client, cfg, "system_update", "OFF").await?;
}
}
CommandAction::InhibitSleep => {
let enable = matches!(value, CommandValue::On);
platform::execute_inhibit_sleep(enable)?;
let state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" };
mqtt::publish_switch_state(client, cfg, "inhibit_sleep", state).await?;
}
CommandAction::TvChannel(app_name) => {
if let CommandValue::Text(channel_name) = &value {
if let Some(app) = cfg.apps.iter().find(|a| a.name == *app_name && a.enabled) {
if let Some(m3u_path) = &app.channels_m3u {
if let Some(channels) = channels_map.get(app_name.as_str()) {
if let Some((_, url)) = channels.iter().find(|(n, _)| n == channel_name) {
let url = url.clone();
// Construire les args sans le chemin M3U, ajouter l'URL de la chaine
let filtered_args: Vec<String> = app.start_args.iter()
.filter(|a| a.as_str() != m3u_path.as_str())
.cloned()
.collect();
let mut channel_args = filtered_args;
channel_args.push(url);
// Arreter l'instance existante, relancer sur la nouvelle chaine
platform::execute_app_stop(&app.process_check)?;
tokio::time::sleep(Duration::from_millis(500)).await;
platform::execute_app_start(&app.start_cmd, &channel_args)?;
let channel_key = format!("{}_channel", app_name);
mqtt::publish_switch_state(client, cfg, &channel_key, channel_name).await?;
let app_key = format!("app_{}", app_name);
mqtt::publish_switch_state(client, cfg, &app_key, "ON").await?;
current_channels.insert(app_name.clone(), channel_name.clone());
} else {
warn!(channel = %channel_name, "channel not found in M3U");
}
}
}
}
}
}
}
Ok(())
}
// Publie l'etat initial des switches HA (par defaut ON).
// Publie l'etat initial des switches HA.
// Commandes momentanees (shutdown/reboot/sleep/hibernate/system_update) : OFF par defaut.
// Commandes avec etat reel (screen, inhibit_sleep, apps, bluetooth) : lues depuis le systeme.
async fn publish_initial_command_states(client: &rumqttc::AsyncClient, cfg: &Config) {
let _ = mqtt::publish_switch_state(client, cfg, "shutdown", "ON").await;
let _ = mqtt::publish_switch_state(client, cfg, "reboot", "ON").await;
let _ = mqtt::publish_switch_state(client, cfg, "sleep", "ON").await;
let _ = mqtt::publish_switch_state(client, cfg, "shutdown", "OFF").await;
let _ = mqtt::publish_switch_state(client, cfg, "reboot", "OFF").await;
let _ = mqtt::publish_switch_state(client, cfg, "sleep", "OFF").await;
let _ = mqtt::publish_switch_state(client, cfg, "hibernate", "OFF").await;
let _ = mqtt::publish_switch_state(client, cfg, "screen", "ON").await;
let _ = mqtt::publish_switch_state(client, cfg, "system_update", "OFF").await;
let inhibit_state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" };
let _ = mqtt::publish_switch_state(client, cfg, "inhibit_sleep", inhibit_state).await;
// Volume initial
if let Some(vol) = platform::read_volume() {
let _ = mqtt::publish_switch_state(client, cfg, "volume", &vol.to_string()).await;
}
// Etat initial des apps
for app in &cfg.apps {
if !app.enabled { continue; }
let running = platform::app_is_running(&app.process_check);
let state = if running { "ON" } else { "OFF" };
let key = format!("app_{}", app.name);
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
}
// Etat initial Bluetooth
if cfg.bluetooth.enabled {
for dev in &cfg.bluetooth.devices {
let connected = platform::bluetooth_is_connected(&dev.mac);
let state = if connected { "ON" } else { "OFF" };
let key = format!("bluetooth_{}", dev.name);
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
}
}
}
// Publie l'etat des apps depuis l'etat interne (pas de pgrep — evite les races conditions).
// L'etat interne est mis a jour quand pilot demarre/arrete une app.
// Un sync pgrep toutes les 30s detecte les changements exterieurs.
async fn publish_app_states(
client: &rumqttc::AsyncClient,
cfg: &Config,
app_states: &HashMap<String, bool>,
) {
for app in &cfg.apps {
if !app.enabled {
continue;
}
let key = format!("app_{}", app.name);
if !cfg.features.commands.allowlist.is_empty()
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
{
continue;
}
let running = app_states.get(&app.name).copied().unwrap_or(false);
let state = if running { "ON" } else { "OFF" };
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
}
// Etat inhibition veille
let inhibit_state = if platform::is_sleep_inhibited() { "ON" } else { "OFF" };
let _ = mqtt::publish_switch_state(client, cfg, "inhibit_sleep", inhibit_state).await;
}
// Verifie et publie l'etat Bluetooth (appelee au heartbeat uniquement — plus lent).
async fn update_bluetooth_states(client: &rumqttc::AsyncClient, cfg: &Config) {
if !cfg.bluetooth.enabled {
return;
}
for dev in &cfg.bluetooth.devices {
let key = format!("bluetooth_{}", dev.name);
if !cfg.features.commands.allowlist.is_empty()
&& !cfg.features.commands.allowlist.iter().any(|a| a == &key)
{
continue;
}
let connected = platform::bluetooth_is_connected(&dev.mac);
let state = if connected { "ON" } else { "OFF" };
let _ = mqtt::publish_switch_state(client, cfg, &key, state).await;
}
}
// Execute l'action d'un key binding.
// Actions supportees :
// "vacuum_tube", "livebox_tv", ... → toggle app (via etat interne app_states)
// "key:28" → injecte le keycode 28 (Enter) via ydotool
// Met a jour app_states directement (pas de retour).
async fn execute_key_binding_action(
client: &rumqttc::AsyncClient,
cfg: &Config,
app_states: &mut HashMap<String, bool>,
action: &str,
) {
// Action "key:<code>" : injection de touche via ydotool
if let Some(code_str) = action.strip_prefix("key:") {
info!(action = %action, "key binding: injecting key via ydotool");
let result = std::process::Command::new("ydotool")
.args(["key", code_str])
.output();
match result {
Ok(out) if out.status.success() => {}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
warn!(code = %code_str, stderr = %stderr, "ydotool key failed");
}
Err(e) => {
warn!(code = %code_str, error = %e, "ydotool not available");
}
}
return;
}
if let Some(app) = cfg.apps.iter().find(|a| a.name == action && a.enabled) {
let key = format!("app_{}", action);
// Utilise l'etat interne pour eviter les races conditions avec pgrep
let is_running = app_states.get(action).copied().unwrap_or(false);
if is_running {
info!(app = %action, "key binding: stopping app");
if let Err(e) = platform::execute_app_stop(&app.process_check) {
warn!(app = %action, error = %e, "key binding: failed to stop app");
}
app_states.insert(action.to_string(), false);
let _ = mqtt::publish_switch_state(client, cfg, &key, "OFF").await;
} else {
info!(app = %action, "key binding: starting app");
match platform::execute_app_start(&app.start_cmd, &app.start_args) {
Ok(_) => {
app_states.insert(action.to_string(), true);
let _ = mqtt::publish_switch_state(client, cfg, &key, "ON").await;
}
Err(e) => {
warn!(app = %action, error = %e, "key binding: failed to start app");
}
}
}
} else {
warn!(action = %action, "key binding: unknown app action");
}
}
// Navigation chaine via touche de telecommande (prog+/prog-).
// Cherche l'app dont channel_next_key ou channel_prev_key correspond a la touche recue,
// calcule le nouvel index (wrapping), relance le lecteur sur la nouvelle chaine.
async fn handle_channel_nav_key(
client: &rumqttc::AsyncClient,
cfg: &Config,
channels_map: &HashMap<String, Vec<(String, String)>>,
current_channels: &mut HashMap<String, String>,
key: &str,
) {
for app in &cfg.apps {
if !app.enabled {
continue;
}
let is_next = app.channel_next_key.as_deref().map_or(false, |k| k == key);
let is_prev = app.channel_prev_key.as_deref().map_or(false, |k| k == key);
if !is_next && !is_prev {
continue;
}
let channels = match channels_map.get(&app.name) {
Some(c) if !c.is_empty() => c,
_ => continue,
};
if !platform::app_is_running(&app.process_check) {
continue;
}
let current = current_channels.get(&app.name).cloned();
let idx = current
.as_deref()
.and_then(|name| channels.iter().position(|(n, _)| n == name))
.unwrap_or(0);
let new_idx = if is_next {
(idx + 1) % channels.len()
} else if idx == 0 {
channels.len() - 1
} else {
idx - 1
};
let (new_channel_name, new_url) = &channels[new_idx];
let url = new_url.clone();
let new_channel_name = new_channel_name.clone();
// Reconstruire les args sans le chemin M3U, ajouter l'URL directe
let filtered_args: Vec<String> = if let Some(m3u_path) = &app.channels_m3u {
app.start_args.iter().filter(|a| a.as_str() != m3u_path.as_str()).cloned().collect()
} else {
app.start_args.clone()
};
let mut channel_args = filtered_args;
channel_args.push(url);
if let Err(e) = platform::execute_app_stop(&app.process_check) {
warn!(error = %e, "channel nav: failed to stop app");
}
tokio::time::sleep(Duration::from_millis(500)).await;
if let Err(e) = platform::execute_app_start(&app.start_cmd, &channel_args) {
warn!(error = %e, "channel nav: failed to start app");
continue;
}
let channel_key = format!("{}_channel", app.name);
let app_key = format!("app_{}", app.name);
let _ = mqtt::publish_switch_state(client, cfg, &channel_key, &new_channel_name).await;
let _ = mqtt::publish_switch_state(client, cfg, &app_key, "ON").await;
current_channels.insert(app.name.clone(), new_channel_name.clone());
info!(app = %app.name, channel = %new_channel_name, direction = if is_next { "next" } else { "prev" }, "channel navigation");
break;
}
}
// Publie l'etat d'une commande pour Home Assistant.
async fn publish_command_state(
client: &rumqttc::AsyncClient,
cfg: &Config,
action: CommandAction,
value: CommandValue,
action: &CommandAction,
value: &CommandValue,
) -> anyhow::Result<()> {
let state = match value {
CommandValue::On => "ON",
CommandValue::Off => "OFF",
CommandValue::On => "ON".to_string(),
CommandValue::Off => "OFF".to_string(),
CommandValue::Number(n) => n.to_string(),
CommandValue::Text(s) => s.clone(),
};
let name = commands::action_name(action);
mqtt::publish_switch_state(client, cfg, name, state).await
mqtt::publish_switch_state(client, cfg, &name, &state).await
}