Skip to main content

homeassistant_cli/commands/
entity.rs

1use owo_colors::OwoColorize;
2
3use crate::api::{self, HaClient, HaError};
4use crate::output::{self, OutputConfig};
5
6pub async fn get(out: &OutputConfig, client: &HaClient, entity_id: &str) -> Result<(), HaError> {
7    let state = api::entities::get_state(client, entity_id).await?;
8
9    if out.is_json() {
10        out.print_data(
11            &serde_json::to_string_pretty(&serde_json::json!({
12                "ok": true,
13                "data": state
14            }))
15            .expect("serialize"),
16        );
17    } else {
18        let attrs = state
19            .attributes
20            .as_object()
21            .map(|m| {
22                m.iter()
23                    .map(|(k, v)| format!("{}={}", k, v))
24                    .collect::<Vec<_>>()
25                    .join("  ")
26            })
27            .unwrap_or_default();
28        let status_sym = if state.state == "on" {
29            "●".green().to_string()
30        } else {
31            "○".dimmed().to_string()
32        };
33        out.print_data(&format!(
34            "{} {}  {}  {}",
35            status_sym,
36            state.entity_id,
37            state.state.bold(),
38            attrs.dimmed()
39        ));
40    }
41    Ok(())
42}
43
44pub async fn list(
45    out: &OutputConfig,
46    client: &HaClient,
47    domain: Option<&str>,
48) -> Result<(), HaError> {
49    let mut states = api::entities::list_states(client).await?;
50
51    if let Some(d) = domain {
52        states.retain(|s| s.entity_id.starts_with(&format!("{d}.")));
53    }
54
55    states.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
56
57    if out.is_json() {
58        out.print_data(
59            &serde_json::to_string_pretty(&serde_json::json!({
60                "ok": true,
61                "data": states
62            }))
63            .expect("serialize"),
64        );
65    } else {
66        let rows: Vec<Vec<String>> = states
67            .iter()
68            .map(|s| vec![s.entity_id.clone(), s.state.clone(), s.last_updated.clone()])
69            .collect();
70        out.print_data(&output::table(&["ENTITY", "STATE", "LAST UPDATED"], &rows));
71    }
72    Ok(())
73}
74
75pub async fn watch(out: &OutputConfig, client: &HaClient, entity_id: &str) -> Result<(), HaError> {
76    out.print_message(&format!("Watching {} (Ctrl+C to stop)...", entity_id));
77
78    let entity_id = entity_id.to_owned();
79    api::events::watch_stream(client, Some("state_changed"), |event| {
80        if let Ok(data) = serde_json::from_value::<crate::api::StateChangedData>(event.data.clone())
81            && data.entity_id == entity_id
82        {
83            if out.is_json() {
84                if let Ok(s) = serde_json::to_string_pretty(&serde_json::json!({
85                    "ok": true,
86                    "data": data
87                })) {
88                    println!("{s}");
89                }
90            } else if let Some(new) = &data.new_state {
91                let status_sym = if new.state == "on" {
92                    "●".green().to_string()
93                } else {
94                    "○".dimmed().to_string()
95                };
96                println!(
97                    "{} {}  {}  {}",
98                    status_sym,
99                    new.entity_id,
100                    new.state.bold(),
101                    new.last_updated.dimmed()
102                );
103            }
104        }
105        true
106    })
107    .await
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::api::HaClient;
114    use crate::output::{OutputConfig, OutputFormat};
115    use wiremock::matchers::{method, path};
116    use wiremock::{Mock, MockServer, ResponseTemplate};
117
118    fn json_out() -> OutputConfig {
119        OutputConfig::new(Some(OutputFormat::Json), false)
120    }
121
122    fn state_json(entity_id: &str, state: &str) -> serde_json::Value {
123        serde_json::json!({
124            "entity_id": entity_id,
125            "state": state,
126            "attributes": {},
127            "last_changed": "2026-01-01T00:00:00Z",
128            "last_updated": "2026-01-01T00:00:00Z"
129        })
130    }
131
132    #[tokio::test]
133    async fn get_returns_ok_for_existing_entity() {
134        let server = MockServer::start().await;
135        Mock::given(method("GET"))
136            .and(path("/api/states/light.x"))
137            .respond_with(ResponseTemplate::new(200).set_body_json(state_json("light.x", "on")))
138            .mount(&server)
139            .await;
140
141        let client = HaClient::new(server.uri(), "tok");
142        let result = get(&json_out(), &client, "light.x").await;
143        assert!(result.is_ok());
144    }
145
146    #[tokio::test]
147    async fn list_returns_ok() {
148        let server = MockServer::start().await;
149        Mock::given(method("GET"))
150            .and(path("/api/states"))
151            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
152                state_json("light.a", "on"),
153                state_json("switch.b", "off"),
154                state_json("light.c", "off"),
155            ])))
156            .mount(&server)
157            .await;
158
159        let client = HaClient::new(server.uri(), "tok");
160        let result = list(&json_out(), &client, Some("light")).await;
161        assert!(result.is_ok());
162    }
163
164    #[tokio::test]
165    async fn get_propagates_not_found() {
166        let server = MockServer::start().await;
167        Mock::given(method("GET"))
168            .and(path("/api/states/light.missing"))
169            .respond_with(ResponseTemplate::new(404))
170            .mount(&server)
171            .await;
172
173        let client = HaClient::new(server.uri(), "tok");
174        let result = get(&json_out(), &client, "light.missing").await;
175        assert!(matches!(result, Err(crate::api::HaError::NotFound(_))));
176    }
177}