|
@@ -143,35 +143,42 @@ pub struct EntityStates {
|
|
|
pub soft_off: Vec<String>,
|
|
pub soft_off: Vec<String>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Probe the cluster's `extra_entities`. No HA config, no entities, or an
|
|
|
|
|
-/// unparseable HA URL → default (nothing to check ≠ off).
|
|
|
|
|
|
|
+/// Probe the cluster's `extra_entities`. No HA config and no `[mikrotik]`
|
|
|
|
|
+/// backend for an entity → that entity is "nothing to check", not off
|
|
|
|
|
+/// (legacy semantics). Mikrotik devices (exact name match from
|
|
|
|
|
+/// [`crate::config`]) are probed through the RouterOS API instead of HA:
|
|
|
|
|
+/// logged in = on, unreachable/rejected login = off (a dead router takes
|
|
|
|
|
+/// the cluster down like any other blocking switch).
|
|
|
pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> EntityStates {
|
|
pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> EntityStates {
|
|
|
- let Some(ha_cfg) = &config.home_assistant else {
|
|
|
|
|
- return EntityStates::default();
|
|
|
|
|
- };
|
|
|
|
|
if cluster.extra_entities.is_empty() {
|
|
if cluster.extra_entities.is_empty() {
|
|
|
return EntityStates::default();
|
|
return EntityStates::default();
|
|
|
}
|
|
}
|
|
|
- let Ok(client) = Client::new(ha_cfg) else {
|
|
|
|
|
- return EntityStates::default();
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ let ha_client: Option<Client> = config
|
|
|
|
|
+ .home_assistant
|
|
|
|
|
+ .as_ref()
|
|
|
|
|
+ .and_then(|ha| Client::new(ha).ok());
|
|
|
|
|
+ let has_ha = ha_client.is_some();
|
|
|
let mut handles = Vec::new();
|
|
let mut handles = Vec::new();
|
|
|
for e in &cluster.extra_entities {
|
|
for e in &cluster.extra_entities {
|
|
|
- // Mikrotik devices (referenced by exact name from [mikrotik]) are
|
|
|
|
|
- // not HA entities — probing them would report "off" and take the
|
|
|
|
|
- // cluster down for nothing.
|
|
|
|
|
|
|
+ let id = e.entity().to_string();
|
|
|
let is_mikrotik = config
|
|
let is_mikrotik = config
|
|
|
.mikrotik
|
|
.mikrotik
|
|
|
.as_ref()
|
|
.as_ref()
|
|
|
- .is_some_and(|m| m.devices.iter().any(|d| d.name == e.entity()));
|
|
|
|
|
- if is_mikrotik {
|
|
|
|
|
|
|
+ .is_some_and(|m| m.devices.iter().any(|d| d.name == id.as_str()));
|
|
|
|
|
+ // A non-mikrotik entity without an HA backend is "nothing to check",
|
|
|
|
|
+ // not off — only entities with a configured backend flip the verdict.
|
|
|
|
|
+ if !is_mikrotik && !has_ha {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- let client = client.clone();
|
|
|
|
|
- let id = e.entity().to_string();
|
|
|
|
|
|
|
+ let config = config.clone();
|
|
|
|
|
+ let client = ha_client.clone();
|
|
|
let blocks = e.blocks();
|
|
let blocks = e.blocks();
|
|
|
handles.push(crate::locale::spawn_with_locale(async move {
|
|
handles.push(crate::locale::spawn_with_locale(async move {
|
|
|
- (id.clone(), blocks, client.get_state(&id).await)
|
|
|
|
|
|
|
+ (
|
|
|
|
|
+ id.clone(),
|
|
|
|
|
+ blocks,
|
|
|
|
|
+ entity_state(&config, client.as_ref(), &id).await,
|
|
|
|
|
+ )
|
|
|
}));
|
|
}));
|
|
|
}
|
|
}
|
|
|
let mut st = EntityStates::default();
|
|
let mut st = EntityStates::default();
|
|
@@ -191,6 +198,37 @@ pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> Entity
|
|
|
st
|
|
st
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// The state of one entity through the correct backend: a `[mikrotik]`
|
|
|
|
|
+/// device is probed with a RouterOS API login (logged in = "on";
|
|
|
|
|
+/// unreachable or rejected login = "off", like a switch that does not
|
|
|
|
|
+/// answer), everything else goes through HA REST.
|
|
|
|
|
+/// Returns `None` only when the entity has no configured backend at all
|
|
|
|
|
+/// (unknown — callers keep the "nothing to check ≠ off" semantics).
|
|
|
|
|
+pub async fn entity_state(
|
|
|
|
|
+ config: &Config,
|
|
|
|
|
+ client: Option<&Client>,
|
|
|
|
|
+ entity_id: &str,
|
|
|
|
|
+) -> Option<String> {
|
|
|
|
|
+ if let Some(dev) = config
|
|
|
|
|
+ .mikrotik
|
|
|
|
|
+ .as_ref()
|
|
|
|
|
+ .and_then(|m| m.devices.iter().find(|d| d.name == entity_id))
|
|
|
|
|
+ {
|
|
|
|
|
+ return match crate::mikrotik::ping_device(dev).await {
|
|
|
|
|
+ Ok(()) => Some("on".to_string()),
|
|
|
|
|
+ Err(e) => {
|
|
|
|
|
+ warn!("RouterOS ping {entity_id} failed: {e}");
|
|
|
|
|
+ Some("off".to_string())
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ let client = client?;
|
|
|
|
|
+ client
|
|
|
|
|
+ .get_state(entity_id)
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map(|s| if s == "on" { "on".into() } else { "off".into() })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build
|
|
/// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build
|
|
|
/// their own endpoints if needed. Exposed for the `power` subcommand
|
|
/// their own endpoints if needed. Exposed for the `power` subcommand
|
|
|
/// and for tests.
|
|
/// and for tests.
|
|
@@ -224,6 +262,53 @@ pub fn warn_if_token_looks_short(token: &str) {
|
|
|
mod tests {
|
|
mod tests {
|
|
|
use super::*;
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
+ #[tokio::test]
|
|
|
|
|
+ async fn mikrotik_entity_unreachable_is_blocking_off() {
|
|
|
|
|
+ // A `[mikrotik]` device referenced from `extra_entities` is probed
|
|
|
|
|
+ // through the RouterOS API: unreachable (closed port) = off, and a
|
|
|
|
|
+ // blocking entity takes the cluster down — the r18-class "dead
|
|
|
|
|
+ // entity silently skipped" failure mode must not exist.
|
|
|
|
|
+ let toml = r#"
|
|
|
|
|
+ssh_user = "u"
|
|
|
|
|
+[[clusters]]
|
|
|
|
|
+name = "c1"
|
|
|
|
|
+nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
|
|
|
|
|
+extra_entities = ["rt"]
|
|
|
|
|
+[mikrotik]
|
|
|
|
|
+[[mikrotik.devices]]
|
|
|
|
|
+name = "rt"
|
|
|
|
|
+address = "127.0.0.1"
|
|
|
|
|
+username = "x"
|
|
|
|
|
+password = "y"
|
|
|
|
|
+api_port = 1
|
|
|
|
|
+"#;
|
|
|
|
|
+ let config: crate::config::Config = toml::from_str(toml).unwrap();
|
|
|
|
|
+ let cluster = &config.clusters[0];
|
|
|
|
|
+ let st = extra_entities_states(&config, cluster).await;
|
|
|
|
|
+ assert!(
|
|
|
|
|
+ st.blocking_off,
|
|
|
|
|
+ "unreachable mikrotik device must be blocking-off"
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[tokio::test]
|
|
|
|
|
+ async fn mikrotik_device_without_backend_not_reported_off() {
|
|
|
|
|
+ // No [mikrotik] section, no HA → the entity has no backend at all:
|
|
|
|
|
+ // "nothing to check", not off (legacy semantics).
|
|
|
|
|
+ let toml = r#"
|
|
|
|
|
+ssh_user = "u"
|
|
|
|
|
+[[clusters]]
|
|
|
|
|
+name = "c1"
|
|
|
|
|
+nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
|
|
|
|
|
+extra_entities = ["rt"]
|
|
|
|
|
+"#;
|
|
|
|
|
+ let config: crate::config::Config = toml::from_str(toml).unwrap();
|
|
|
|
|
+ let cluster = &config.clusters[0];
|
|
|
|
|
+ let st = extra_entities_states(&config, cluster).await;
|
|
|
|
|
+ assert!(!st.blocking_off);
|
|
|
|
|
+ assert!(st.soft_off.is_empty());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn split_entity_basic() {
|
|
fn split_entity_basic() {
|
|
|
assert_eq!(split_entity("switch.dgx1").unwrap(), ("switch", "dgx1"));
|
|
assert_eq!(split_entity("switch.dgx1").unwrap(), ("switch", "dgx1"));
|