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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user