//! 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, config: Config, } impl HaPowerController { pub fn new(ha: HaClient, config: Config) -> Self { Self { ha, config } } } impl PowerController for HaPowerController { async fn entity_is_on(&self, entity: &str) -> Option { // `[mikrotik]` device: liveness through the RouterOS API login // (logged in = on; unreachable/rejected = off, like a dead switch). if let Some(dev) = self .config .mikrotik .as_ref() .and_then(|m| m.devices.iter().find(|d| d.name == entity)) { return Some(crate::mikrotik::ping_device(dev).await.is_ok()); } self.ha.get_state(entity).await.map(|s| s == "on") } async fn turn_on(&self, entity: &str) -> Result<()> { // RouterOS has no power-ON API: route through the device's // `on_entity` HA relay if configured; otherwise the operator plugs // it in manually (same semantics as `power on`), never an error. if let Some(dev) = self .config .mikrotik .as_ref() .and_then(|m| m.devices.iter().find(|d| d.name == entity)) { match &dev.on_entity { Some(relay) => self.ha.turn_on(relay).await?, None => { warn!("RouterOS {entity} has no on_entity relay — power it on manually"); } } return Ok(()); } 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, config.clone())) } /// 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() .map(|e| e.entity().to_string()), ); entities }