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    state_filter: Option<&str>,
49    limit: usize,
50    offset: usize,
51    fields: Option<&str>,
52) -> Result<(), HaError> {
53    let mut states = api::entities::list_states(client).await?;
54
55    if let Some(d) = domain {
56        states.retain(|s| s.entity_id.starts_with(&format!("{d}.")));
57    }
58    if let Some(st) = state_filter {
59        states.retain(|s| s.state == st);
60    }
61
62    states.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
63
64    let total = states.len();
65
66    // Apply offset before slicing to the requested page.
67    let states = if offset < states.len() {
68        &states[offset..]
69    } else {
70        &[][..]
71    };
72    let states: Vec<_> = states.iter().take(limit).collect();
73
74    let field_filter: Option<Vec<&str>> = fields.map(|f| f.split(',').map(str::trim).collect());
75
76    if out.is_json() {
77        let items: Vec<serde_json::Value> = states
78            .iter()
79            .map(|s| {
80                let mut obj = serde_json::json!({
81                    "entity_id": s.entity_id,
82                    "state": s.state,
83                    "attributes": s.attributes,
84                    "last_changed": s.last_changed,
85                    "last_updated": s.last_updated,
86                });
87                if let (Some(ff), Some(map)) = (&field_filter, obj.as_object_mut()) {
88                    map.retain(|k, _| ff.contains(&k.as_str()));
89                }
90                obj
91            })
92            .collect();
93        out.print_data(
94            &serde_json::to_string_pretty(&serde_json::json!({
95                "items": items,
96                "total": total,
97                "limit": limit,
98                "offset": offset,
99            }))
100            .expect("serialize"),
101        );
102    } else {
103        let default_fields = vec!["entity_id", "name", "state", "last_updated"];
104        let show_fields: &[&str] = field_filter.as_deref().unwrap_or(&default_fields);
105        let rows: Vec<Vec<String>> = states
106            .iter()
107            .map(|s| {
108                show_fields
109                    .iter()
110                    .map(|f| match *f {
111                        "entity_id" => output::colored_entity_id(&s.entity_id),
112                        "name" => s
113                            .attributes
114                            .get("friendly_name")
115                            .and_then(|v| v.as_str())
116                            .unwrap_or("")
117                            .to_owned(),
118                        "state" => output::colored_state(&s.state),
119                        "last_updated" | "updated" => output::relative_time(&s.last_updated),
120                        "last_changed" | "changed" => output::relative_time(&s.last_changed),
121                        other => s
122                            .attributes
123                            .get(other)
124                            .map(|v| v.to_string())
125                            .unwrap_or_default(),
126                    })
127                    .collect()
128            })
129            .collect();
130        let headers: Vec<&str> = show_fields
131            .iter()
132            .map(|f| match *f {
133                "entity_id" => "ENTITY",
134                "name" => "NAME",
135                "state" => "STATE",
136                "last_updated" | "updated" => "UPDATED",
137                "last_changed" | "changed" => "CHANGED",
138                other => other,
139            })
140            .collect();
141        out.print_data(&output::table(&headers, &rows));
142        if total > offset + limit {
143            out.print_message(&format!(
144                "Showing {limit} of {total} (use --offset {} to see more)",
145                offset + limit
146            ));
147        }
148    }
149    Ok(())
150}
151
152pub async fn watch(out: &OutputConfig, client: &HaClient, entity_id: &str) -> Result<(), HaError> {
153    out.print_message(&format!("Watching {} (Ctrl+C to stop)...", entity_id));
154
155    let entity_id = entity_id.to_owned();
156    api::events::watch_stream(client, Some("state_changed"), |event| {
157        if let Ok(data) = serde_json::from_value::<crate::api::StateChangedData>(event.data.clone())
158            && data.entity_id == entity_id
159        {
160            if out.is_json() {
161                if let Ok(s) = serde_json::to_string_pretty(&serde_json::json!({
162                    "ok": true,
163                    "data": data
164                })) {
165                    println!("{s}");
166                }
167            } else if let Some(new) = &data.new_state {
168                let status_sym = if new.state == "on" {
169                    "●".green().to_string()
170                } else {
171                    "○".dimmed().to_string()
172                };
173                println!(
174                    "{} {}  {}  {}",
175                    status_sym,
176                    new.entity_id,
177                    new.state.bold(),
178                    output::relative_time(&new.last_updated).dimmed()
179                );
180            }
181        }
182        true
183    })
184    .await
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::api::HaClient;
191    use crate::output::{OutputConfig, OutputFormat};
192    use wiremock::matchers::{method, path};
193    use wiremock::{Mock, MockServer, ResponseTemplate};
194
195    fn json_out() -> OutputConfig {
196        OutputConfig::new(Some(OutputFormat::Json), false)
197    }
198
199    fn state_json(entity_id: &str, state: &str) -> serde_json::Value {
200        serde_json::json!({
201            "entity_id": entity_id,
202            "state": state,
203            "attributes": {},
204            "last_changed": "2026-01-01T00:00:00Z",
205            "last_updated": "2026-01-01T00:00:00Z"
206        })
207    }
208
209    #[tokio::test]
210    async fn get_returns_ok_for_existing_entity() {
211        let server = MockServer::start().await;
212        Mock::given(method("GET"))
213            .and(path("/api/states/light.x"))
214            .respond_with(ResponseTemplate::new(200).set_body_json(state_json("light.x", "on")))
215            .mount(&server)
216            .await;
217
218        let client = HaClient::new(server.uri(), "tok");
219        let result = get(&json_out(), &client, "light.x").await;
220        assert!(result.is_ok());
221    }
222
223    #[tokio::test]
224    async fn list_returns_ok() {
225        let server = MockServer::start().await;
226        Mock::given(method("GET"))
227            .and(path("/api/states"))
228            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
229                state_json("light.a", "on"),
230                state_json("switch.b", "off"),
231                state_json("light.c", "off"),
232            ])))
233            .mount(&server)
234            .await;
235
236        let client = HaClient::new(server.uri(), "tok");
237        let result = list(&json_out(), &client, Some("light"), None, 100, 0, None).await;
238        assert!(result.is_ok());
239    }
240
241    #[tokio::test]
242    async fn list_json_output_includes_pagination_metadata() {
243        let server = MockServer::start().await;
244        Mock::given(method("GET"))
245            .and(path("/api/states"))
246            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
247                state_json("light.a", "on"),
248                state_json("light.b", "off"),
249                state_json("light.c", "on"),
250            ])))
251            .mount(&server)
252            .await;
253
254        let client = HaClient::new(server.uri(), "tok");
255        // Capture stdout is not straightforward in unit tests; exercise the code
256        // path and verify no errors. The pagination metadata fields are validated
257        // via schema tests.
258        let result = list(&json_out(), &client, None, None, 2, 0, None).await;
259        assert!(result.is_ok(), "list with limit should succeed");
260    }
261
262    #[tokio::test]
263    async fn list_applies_offset_correctly() {
264        let server = MockServer::start().await;
265        Mock::given(method("GET"))
266            .and(path("/api/states"))
267            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
268                state_json("light.a", "on"),
269                state_json("light.b", "off"),
270                state_json("light.c", "on"),
271            ])))
272            .mount(&server)
273            .await;
274
275        let client = HaClient::new(server.uri(), "tok");
276        let result = list(&json_out(), &client, None, None, 100, 2, None).await;
277        assert!(result.is_ok(), "offset beyond some items should succeed");
278    }
279
280    #[tokio::test]
281    async fn get_propagates_not_found() {
282        let server = MockServer::start().await;
283        Mock::given(method("GET"))
284            .and(path("/api/states/light.missing"))
285            .respond_with(ResponseTemplate::new(404))
286            .mount(&server)
287            .await;
288
289        let client = HaClient::new(server.uri(), "tok");
290        let result = get(&json_out(), &client, "light.missing").await;
291        assert!(matches!(result, Err(crate::api::HaError::NotFound(_))));
292    }
293}