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) -> 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| {
69 let name = s
70 .attributes
71 .get("friendly_name")
72 .and_then(|v| v.as_str())
73 .unwrap_or("")
74 .to_owned();
75 vec![
76 output::colored_entity_id(&s.entity_id),
77 name,
78 output::colored_state(&s.state),
79 output::relative_time(&s.last_updated),
80 ]
81 })
82 .collect();
83 out.print_data(&output::table(
84 &["ENTITY", "NAME", "STATE", "UPDATED"],
85 &rows,
86 ));
87 }
88 Ok(())
89}
90
91pub async fn watch(out: &OutputConfig, client: &HaClient, entity_id: &str) -> Result<(), HaError> {
92 out.print_message(&format!("Watching {} (Ctrl+C to stop)...", entity_id));
93
94 let entity_id = entity_id.to_owned();
95 api::events::watch_stream(client, Some("state_changed"), |event| {
96 if let Ok(data) = serde_json::from_value::<crate::api::StateChangedData>(event.data.clone())
97 && data.entity_id == entity_id
98 {
99 if out.is_json() {
100 if let Ok(s) = serde_json::to_string_pretty(&serde_json::json!({
101 "ok": true,
102 "data": data
103 })) {
104 println!("{s}");
105 }
106 } else if let Some(new) = &data.new_state {
107 let status_sym = if new.state == "on" {
108 "●".green().to_string()
109 } else {
110 "○".dimmed().to_string()
111 };
112 println!(
113 "{} {} {} {}",
114 status_sym,
115 new.entity_id,
116 new.state.bold(),
117 output::relative_time(&new.last_updated).dimmed()
118 );
119 }
120 }
121 true
122 })
123 .await
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::api::HaClient;
130 use crate::output::{OutputConfig, OutputFormat};
131 use wiremock::matchers::{method, path};
132 use wiremock::{Mock, MockServer, ResponseTemplate};
133
134 fn json_out() -> OutputConfig {
135 OutputConfig::new(Some(OutputFormat::Json), false)
136 }
137
138 fn state_json(entity_id: &str, state: &str) -> serde_json::Value {
139 serde_json::json!({
140 "entity_id": entity_id,
141 "state": state,
142 "attributes": {},
143 "last_changed": "2026-01-01T00:00:00Z",
144 "last_updated": "2026-01-01T00:00:00Z"
145 })
146 }
147
148 #[tokio::test]
149 async fn get_returns_ok_for_existing_entity() {
150 let server = MockServer::start().await;
151 Mock::given(method("GET"))
152 .and(path("/api/states/light.x"))
153 .respond_with(ResponseTemplate::new(200).set_body_json(state_json("light.x", "on")))
154 .mount(&server)
155 .await;
156
157 let client = HaClient::new(server.uri(), "tok");
158 let result = get(&json_out(), &client, "light.x").await;
159 assert!(result.is_ok());
160 }
161
162 #[tokio::test]
163 async fn list_returns_ok() {
164 let server = MockServer::start().await;
165 Mock::given(method("GET"))
166 .and(path("/api/states"))
167 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
168 state_json("light.a", "on"),
169 state_json("switch.b", "off"),
170 state_json("light.c", "off"),
171 ])))
172 .mount(&server)
173 .await;
174
175 let client = HaClient::new(server.uri(), "tok");
176 let result = list(&json_out(), &client, Some("light")).await;
177 assert!(result.is_ok());
178 }
179
180 #[tokio::test]
181 async fn get_propagates_not_found() {
182 let server = MockServer::start().await;
183 Mock::given(method("GET"))
184 .and(path("/api/states/light.missing"))
185 .respond_with(ResponseTemplate::new(404))
186 .mount(&server)
187 .await;
188
189 let client = HaClient::new(server.uri(), "tok");
190 let result = get(&json_out(), &client, "light.missing").await;
191 assert!(matches!(result, Err(crate::api::HaError::NotFound(_))));
192 }
193}