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

@@ -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]