//! End-effector traits — abstract operations that touch real hardware //! (SSH, Home Assistant, Docker). Control logic in [`cluster_state`] //! and [`orchestrate`] works with these traits so it can be tested //! without live infrastructure. use anyhow::Result; use crate::cluster_state::ClusterState; use crate::config::{Cluster, Config, Node}; /// Probe nodes and return their aggregate state. pub trait NodeProber { /// Probe all unique nodes across the config. fn probe(&self, config: &Config) -> impl std::future::Future + Send; } /// Power on/off smart plugs via Home Assistant. /// /// Intentionally models only the **power-on** path (`entity_is_on` / /// `turn_on`): orchestrate's `prepare_cluster` auto-powers-on a cluster /// when nodes are unreachable, and that is the only place the trait is /// used. `power off` is a direct user command (`power.rs`) that talks to /// `HaClient` directly — it does not flow through orchestrate, so it has /// no need of the trait abstraction. If orchestrate ever needs to power /// off, add `turn_off` here and route `power_off` through it. pub trait PowerController { /// Check whether a HA entity is currently in state `"on"`. fn entity_is_on(&self, entity: &str) -> impl std::future::Future> + Send; /// Turn on a HA entity (power on the node/switch). fn turn_on(&self, entity: &str) -> impl std::future::Future> + Send; } /// Ping a node via SSH to test reachability. pub trait NodePinger { /// Whether the node accepts SSH connections. fn is_reachable( &self, config: &Config, node: &Node, ) -> impl std::future::Future + Send; } // --------------------------------------------------------------------------- // Real implementations // --------------------------------------------------------------------------- use crate::home_assistant::Client as HaClient; use crate::home_assistant::warn_if_token_looks_short; use crate::ssh::run_ssh; use tracing::warn; pub struct SshProber; impl NodeProber for SshProber { async fn probe(&self, config: &Config) -> ClusterState { ClusterState::probe(config).await } } pub struct HaPowerController { ha: HaClient, } impl HaPowerController { pub fn new(ha: HaClient) -> Self { Self { ha } } } impl PowerController for HaPowerController { async fn entity_is_on(&self, entity: &str) -> Option { self.ha.get_state(entity).await.map(|s| s == "on") } async fn turn_on(&self, entity: &str) -> Result<()> { self.ha.turn_on(entity).await } } pub struct SshNodePinger; impl NodePinger for SshNodePinger { async fn is_reachable(&self, config: &Config, node: &Node) -> bool { run_ssh(config, node, "echo ok".into(), false).await.is_ok() } } /// Build a [`HaPowerController`] from config, returning `None` if HA is /// not configured. pub fn ha_controller_from_config(config: &Config) -> Option { let ha_cfg = config.home_assistant.as_ref()?; let ha = match HaClient::new(ha_cfg) { Ok(c) => c, Err(e) => { warn!("Failed to construct HA client: {}", e); return None; } }; warn_if_token_looks_short(&ha_cfg.token); Some(HaPowerController::new(ha)) } /// Collect all entity IDs (per-node + cluster extras) for a cluster. pub fn cluster_entities(cluster: &Cluster) -> Vec { let mut entities: Vec = Vec::new(); for node in &cluster.nodes { if let Some(entity) = &node.entity { entities.push(entity.clone()); } } entities.extend(cluster.extra_entities.iter().cloned()); entities }