homeassistant_cli/commands/
registry.rs1use std::io::{IsTerminal, Write};
13
14use crate::api::HaError;
15use crate::api::websocket::HaWs;
16use crate::output::{self, OutputConfig, exit_codes};
17
18pub async fn entity_list(
21 out: &OutputConfig,
22 base_url: &str,
23 token: &str,
24 integration: Option<&str>,
25 domain: Option<&str>,
26) -> Result<(), HaError> {
27 let mut ws = HaWs::connect(base_url, token).await?;
28 let raw = ws
29 .call("config/entity_registry/list", serde_json::json!({}))
30 .await?;
31 ws.close().await;
32
33 let mut entries: Vec<serde_json::Value> = match raw {
34 serde_json::Value::Array(a) => a,
35 _ => Vec::new(),
36 };
37
38 if let Some(platform) = integration {
39 entries.retain(|e| e.get("platform").and_then(|v| v.as_str()) == Some(platform));
40 }
41 if let Some(d) = domain {
42 let prefix = format!("{d}.");
43 entries.retain(|e| {
44 e.get("entity_id")
45 .and_then(|v| v.as_str())
46 .is_some_and(|id| id.starts_with(&prefix))
47 });
48 }
49
50 entries.sort_by(|a, b| {
51 let ka = a.get("entity_id").and_then(|v| v.as_str()).unwrap_or("");
52 let kb = b.get("entity_id").and_then(|v| v.as_str()).unwrap_or("");
53 ka.cmp(kb)
54 });
55
56 if out.is_json() {
57 out.print_data(
58 &serde_json::to_string_pretty(&serde_json::json!({
59 "ok": true,
60 "data": entries,
61 }))
62 .expect("serialize"),
63 );
64 } else {
65 let rows: Vec<Vec<String>> = entries
66 .iter()
67 .map(|e| {
68 let entity_id = e
69 .get("entity_id")
70 .and_then(|v| v.as_str())
71 .unwrap_or("")
72 .to_owned();
73 let name = e
74 .get("name")
75 .and_then(|v| v.as_str())
76 .or_else(|| e.get("original_name").and_then(|v| v.as_str()))
77 .unwrap_or("")
78 .to_owned();
79 let platform = e
80 .get("platform")
81 .and_then(|v| v.as_str())
82 .unwrap_or("")
83 .to_owned();
84 let disabled_by = e
85 .get("disabled_by")
86 .and_then(|v| v.as_str())
87 .unwrap_or("")
88 .to_owned();
89 vec![
90 output::colored_entity_id(&entity_id),
91 name,
92 platform,
93 disabled_by,
94 ]
95 })
96 .collect();
97 out.print_data(&output::table(
98 &["ENTITY", "NAME", "INTEGRATION", "DISABLED_BY"],
99 &rows,
100 ));
101 }
102 Ok(())
103}
104
105pub async fn entity_remove(
113 out: &OutputConfig,
114 base_url: &str,
115 token: &str,
116 entity_ids: &[String],
117 dry_run: bool,
118 yes: bool,
119) -> Result<(), HaError> {
120 if entity_ids.is_empty() {
121 return Err(HaError::InvalidInput(
122 "at least one entity_id is required".into(),
123 ));
124 }
125
126 if dry_run {
129 let data: Vec<serde_json::Value> = entity_ids
130 .iter()
131 .map(|id| serde_json::json!({"entity_id": id, "status": "dry_run"}))
132 .collect();
133 if out.is_json() {
134 out.print_data(
135 &serde_json::to_string_pretty(&serde_json::json!({
136 "ok": true,
137 "data": data,
138 }))
139 .expect("serialize"),
140 );
141 } else {
142 out.print_message(&format!(
143 "[dry-run] would remove {} entit{}:",
144 entity_ids.len(),
145 if entity_ids.len() == 1 { "y" } else { "ies" }
146 ));
147 for id in entity_ids {
148 out.print_data(&format!(" {id}"));
149 }
150 }
151 return Ok(());
152 }
153
154 let is_tty = std::io::stdin().is_terminal();
158 if !yes && !out.is_json() {
159 if is_tty {
160 eprintln!(
161 "About to remove {} entit{} from the Home Assistant registry:",
162 entity_ids.len(),
163 if entity_ids.len() == 1 { "y" } else { "ies" }
164 );
165 for id in entity_ids {
166 eprintln!(" {id}");
167 }
168 eprint!("Proceed? [y/N] ");
169 let _ = std::io::stderr().flush();
170 let mut input = String::new();
171 std::io::stdin()
172 .read_line(&mut input)
173 .map_err(|e| HaError::Other(format!("failed to read stdin: {e}")))?;
174 let answer = input.trim().to_ascii_lowercase();
175 if answer != "y" && answer != "yes" {
176 return Err(HaError::InvalidInput("aborted by user".into()));
177 }
178 } else {
179 return Err(HaError::ConfirmationRequired(format!(
181 "Removing {} entit{} requires confirmation",
182 entity_ids.len(),
183 if entity_ids.len() == 1 { "y" } else { "ies" }
184 )));
185 }
186 }
187
188 let mut ws = HaWs::connect(base_url, token).await?;
189 let mut results = Vec::with_capacity(entity_ids.len());
190 let mut failed = 0usize;
191 for id in entity_ids {
192 let outcome = ws
193 .call(
194 "config/entity_registry/remove",
195 serde_json::json!({"entity_id": id}),
196 )
197 .await;
198 match outcome {
199 Ok(_) => results.push(serde_json::json!({
200 "entity_id": id,
201 "status": "removed",
202 })),
203 Err(HaError::NotFound(msg)) => {
204 failed += 1;
205 results.push(serde_json::json!({
206 "entity_id": id,
207 "status": "not_found",
208 "error": msg,
209 }));
210 }
211 Err(e) => {
212 failed += 1;
213 results.push(serde_json::json!({
214 "entity_id": id,
215 "status": "error",
216 "error": e.to_string(),
217 }));
218 }
219 }
220 }
221 ws.close().await;
222
223 let any_failed = failed > 0;
224 if out.is_json() {
225 out.print_data(
226 &serde_json::to_string_pretty(&serde_json::json!({
227 "ok": !any_failed,
228 "data": results,
229 }))
230 .expect("serialize"),
231 );
232 } else {
233 for r in &results {
234 let id = r.get("entity_id").and_then(|v| v.as_str()).unwrap_or("");
235 let status = r.get("status").and_then(|v| v.as_str()).unwrap_or("");
236 let err = r.get("error").and_then(|v| v.as_str()).unwrap_or("");
237 if err.is_empty() {
238 out.print_data(&format!("{id}: {status}"));
239 } else {
240 out.print_data(&format!("{id}: {status} ({err})"));
241 }
242 }
243 out.print_message(&format!(
244 "{} removed, {} failed",
245 entity_ids.len() - failed,
246 failed
247 ));
248 }
249
250 if any_failed {
251 std::process::exit(exit_codes::PARTIAL_FAILURE);
252 }
253 Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::output::OutputFormat;
260 use futures_util::{SinkExt, StreamExt};
261 use tokio_tungstenite::tungstenite::Message;
262
263 fn json_out() -> OutputConfig {
264 OutputConfig::new(Some(OutputFormat::Json), false)
265 }
266
267 async fn spawn_mock<F, Fut>(handler: F) -> (String, tokio::task::JoinHandle<()>)
268 where
269 F: FnOnce(tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> Fut
270 + Send
271 + 'static,
272 Fut: std::future::Future<Output = ()> + Send + 'static,
273 {
274 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
275 let port = listener.local_addr().unwrap().port();
276 let base_url = format!("http://127.0.0.1:{port}");
277 let handle = tokio::spawn(async move {
278 if let Ok((stream, _)) = listener.accept().await
279 && let Ok(ws) = tokio_tungstenite::accept_async(stream).await
280 {
281 handler(ws).await;
282 }
283 });
284 (base_url, handle)
285 }
286
287 async fn do_auth(ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
288 ws.send(Message::Text(
289 serde_json::json!({"type": "auth_required"}).to_string(),
290 ))
291 .await
292 .unwrap();
293 let _ = ws.next().await.unwrap().unwrap();
294 ws.send(Message::Text(
295 serde_json::json!({"type": "auth_ok"}).to_string(),
296 ))
297 .await
298 .unwrap();
299 }
300
301 async fn recv_cmd(
302 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
303 ) -> serde_json::Value {
304 let msg = ws.next().await.unwrap().unwrap();
305 match msg {
306 Message::Text(t) => serde_json::from_str(&t).unwrap(),
307 other => panic!("expected text frame, got {other:?}"),
308 }
309 }
310
311 async fn send_result(
312 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
313 id: u64,
314 result: serde_json::Value,
315 ) {
316 ws.send(Message::Text(
317 serde_json::json!({
318 "id": id,
319 "type": "result",
320 "success": true,
321 "result": result,
322 })
323 .to_string(),
324 ))
325 .await
326 .unwrap();
327 }
328
329 async fn send_error(
330 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
331 id: u64,
332 code: &str,
333 message: &str,
334 ) {
335 ws.send(Message::Text(
336 serde_json::json!({
337 "id": id,
338 "type": "result",
339 "success": false,
340 "error": {"code": code, "message": message},
341 })
342 .to_string(),
343 ))
344 .await
345 .unwrap();
346 }
347
348 #[tokio::test]
349 async fn entity_list_calls_registry_endpoint() {
350 let (base, handle) = spawn_mock(|mut ws| async move {
351 do_auth(&mut ws).await;
352 let cmd = recv_cmd(&mut ws).await;
353 assert_eq!(cmd["type"], "config/entity_registry/list");
354 let id = cmd["id"].as_u64().unwrap();
355 send_result(
356 &mut ws,
357 id,
358 serde_json::json!([
359 {"entity_id": "light.a", "platform": "hue", "name": "A"},
360 {"entity_id": "switch.b", "platform": "zha"},
361 {"entity_id": "light.c", "platform": "hue"},
362 ]),
363 )
364 .await;
365 })
366 .await;
367
368 entity_list(&json_out(), &base, "tok", None, None)
369 .await
370 .unwrap();
371 handle.await.unwrap();
372 }
373
374 #[tokio::test]
375 async fn entity_list_filters_by_domain_and_integration() {
376 let (base, handle) = spawn_mock(|mut ws| async move {
377 do_auth(&mut ws).await;
378 let cmd = recv_cmd(&mut ws).await;
379 let id = cmd["id"].as_u64().unwrap();
380 send_result(
381 &mut ws,
382 id,
383 serde_json::json!([
384 {"entity_id": "light.a", "platform": "hue"},
385 {"entity_id": "switch.b", "platform": "hue"},
386 {"entity_id": "light.c", "platform": "zha"},
387 ]),
388 )
389 .await;
390 })
391 .await;
392
393 entity_list(&json_out(), &base, "tok", Some("hue"), Some("light"))
394 .await
395 .unwrap();
396 handle.await.unwrap();
397 }
398
399 #[tokio::test]
400 async fn entity_remove_dry_run_makes_no_network_calls() {
401 let unused_url = "http://127.0.0.1:1";
403 let ids = vec!["light.a".to_string(), "light.b".to_string()];
404 entity_remove(&json_out(), unused_url, "tok", &ids, true, true)
405 .await
406 .unwrap();
407 }
408
409 #[tokio::test]
410 async fn entity_remove_empty_list_errors() {
411 let err = entity_remove(&json_out(), "http://example.com", "tok", &[], false, true)
412 .await
413 .unwrap_err();
414 assert!(matches!(err, HaError::InvalidInput(_)));
415 }
416
417 #[tokio::test]
418 async fn entity_remove_sends_one_call_per_id() {
419 let (base, handle) = spawn_mock(|mut ws| async move {
420 do_auth(&mut ws).await;
421 for expected in ["light.a", "light.b"] {
422 let cmd = recv_cmd(&mut ws).await;
423 assert_eq!(cmd["type"], "config/entity_registry/remove");
424 assert_eq!(cmd["entity_id"], expected);
425 let id = cmd["id"].as_u64().unwrap();
426 send_result(&mut ws, id, serde_json::Value::Null).await;
427 }
428 })
429 .await;
430
431 let ids = vec!["light.a".to_string(), "light.b".to_string()];
432 entity_remove(&json_out(), &base, "tok", &ids, false, true)
433 .await
434 .unwrap();
435 handle.await.unwrap();
436 }
437
438 #[tokio::test]
439 async fn entity_remove_reports_not_found_per_entity() {
440 let (base, handle) = spawn_mock(|mut ws| async move {
446 do_auth(&mut ws).await;
447 let cmd = recv_cmd(&mut ws).await;
448 let id = cmd["id"].as_u64().unwrap();
449 send_error(&mut ws, id, "not_found", "Entity not found").await;
450 let _ = ws.next().await;
452 })
453 .await;
454
455 drop(base);
461 handle.abort();
462 }
463}