homeassistant_cli/commands/
entity.rs1use 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 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 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}