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 auto_confirm = yes || out.is_json() || !std::io::stdin().is_terminal();
156 if !auto_confirm {
157 eprintln!(
158 "About to remove {} entit{} from the Home Assistant registry:",
159 entity_ids.len(),
160 if entity_ids.len() == 1 { "y" } else { "ies" }
161 );
162 for id in entity_ids {
163 eprintln!(" {id}");
164 }
165 eprint!("Proceed? [y/N] ");
166 let _ = std::io::stderr().flush();
167 let mut input = String::new();
168 std::io::stdin()
169 .read_line(&mut input)
170 .map_err(|e| HaError::Other(format!("failed to read stdin: {e}")))?;
171 let answer = input.trim().to_ascii_lowercase();
172 if answer != "y" && answer != "yes" {
173 return Err(HaError::InvalidInput("aborted by user".into()));
174 }
175 }
176
177 let mut ws = HaWs::connect(base_url, token).await?;
178 let mut results = Vec::with_capacity(entity_ids.len());
179 let mut failed = 0usize;
180 for id in entity_ids {
181 let outcome = ws
182 .call(
183 "config/entity_registry/remove",
184 serde_json::json!({"entity_id": id}),
185 )
186 .await;
187 match outcome {
188 Ok(_) => results.push(serde_json::json!({
189 "entity_id": id,
190 "status": "removed",
191 })),
192 Err(HaError::NotFound(msg)) => {
193 failed += 1;
194 results.push(serde_json::json!({
195 "entity_id": id,
196 "status": "not_found",
197 "error": msg,
198 }));
199 }
200 Err(e) => {
201 failed += 1;
202 results.push(serde_json::json!({
203 "entity_id": id,
204 "status": "error",
205 "error": e.to_string(),
206 }));
207 }
208 }
209 }
210 ws.close().await;
211
212 let any_failed = failed > 0;
213 if out.is_json() {
214 out.print_data(
215 &serde_json::to_string_pretty(&serde_json::json!({
216 "ok": !any_failed,
217 "data": results,
218 }))
219 .expect("serialize"),
220 );
221 } else {
222 for r in &results {
223 let id = r.get("entity_id").and_then(|v| v.as_str()).unwrap_or("");
224 let status = r.get("status").and_then(|v| v.as_str()).unwrap_or("");
225 let err = r.get("error").and_then(|v| v.as_str()).unwrap_or("");
226 if err.is_empty() {
227 out.print_data(&format!("{id}: {status}"));
228 } else {
229 out.print_data(&format!("{id}: {status} ({err})"));
230 }
231 }
232 out.print_message(&format!(
233 "{} removed, {} failed",
234 entity_ids.len() - failed,
235 failed
236 ));
237 }
238
239 if any_failed {
240 std::process::exit(exit_codes::PARTIAL_FAILURE);
241 }
242 Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::output::OutputFormat;
249 use futures_util::{SinkExt, StreamExt};
250 use tokio_tungstenite::tungstenite::Message;
251
252 fn json_out() -> OutputConfig {
253 OutputConfig::new(Some(OutputFormat::Json), false)
254 }
255
256 async fn spawn_mock<F, Fut>(handler: F) -> (String, tokio::task::JoinHandle<()>)
257 where
258 F: FnOnce(tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> Fut
259 + Send
260 + 'static,
261 Fut: std::future::Future<Output = ()> + Send + 'static,
262 {
263 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
264 let port = listener.local_addr().unwrap().port();
265 let base_url = format!("http://127.0.0.1:{port}");
266 let handle = tokio::spawn(async move {
267 if let Ok((stream, _)) = listener.accept().await
268 && let Ok(ws) = tokio_tungstenite::accept_async(stream).await
269 {
270 handler(ws).await;
271 }
272 });
273 (base_url, handle)
274 }
275
276 async fn do_auth(ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
277 ws.send(Message::Text(
278 serde_json::json!({"type": "auth_required"}).to_string(),
279 ))
280 .await
281 .unwrap();
282 let _ = ws.next().await.unwrap().unwrap();
283 ws.send(Message::Text(
284 serde_json::json!({"type": "auth_ok"}).to_string(),
285 ))
286 .await
287 .unwrap();
288 }
289
290 async fn recv_cmd(
291 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
292 ) -> serde_json::Value {
293 let msg = ws.next().await.unwrap().unwrap();
294 match msg {
295 Message::Text(t) => serde_json::from_str(&t).unwrap(),
296 other => panic!("expected text frame, got {other:?}"),
297 }
298 }
299
300 async fn send_result(
301 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
302 id: u64,
303 result: serde_json::Value,
304 ) {
305 ws.send(Message::Text(
306 serde_json::json!({
307 "id": id,
308 "type": "result",
309 "success": true,
310 "result": result,
311 })
312 .to_string(),
313 ))
314 .await
315 .unwrap();
316 }
317
318 async fn send_error(
319 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
320 id: u64,
321 code: &str,
322 message: &str,
323 ) {
324 ws.send(Message::Text(
325 serde_json::json!({
326 "id": id,
327 "type": "result",
328 "success": false,
329 "error": {"code": code, "message": message},
330 })
331 .to_string(),
332 ))
333 .await
334 .unwrap();
335 }
336
337 #[tokio::test]
338 async fn entity_list_calls_registry_endpoint() {
339 let (base, handle) = spawn_mock(|mut ws| async move {
340 do_auth(&mut ws).await;
341 let cmd = recv_cmd(&mut ws).await;
342 assert_eq!(cmd["type"], "config/entity_registry/list");
343 let id = cmd["id"].as_u64().unwrap();
344 send_result(
345 &mut ws,
346 id,
347 serde_json::json!([
348 {"entity_id": "light.a", "platform": "hue", "name": "A"},
349 {"entity_id": "switch.b", "platform": "zha"},
350 {"entity_id": "light.c", "platform": "hue"},
351 ]),
352 )
353 .await;
354 })
355 .await;
356
357 entity_list(&json_out(), &base, "tok", None, None)
358 .await
359 .unwrap();
360 handle.await.unwrap();
361 }
362
363 #[tokio::test]
364 async fn entity_list_filters_by_domain_and_integration() {
365 let (base, handle) = spawn_mock(|mut ws| async move {
366 do_auth(&mut ws).await;
367 let cmd = recv_cmd(&mut ws).await;
368 let id = cmd["id"].as_u64().unwrap();
369 send_result(
370 &mut ws,
371 id,
372 serde_json::json!([
373 {"entity_id": "light.a", "platform": "hue"},
374 {"entity_id": "switch.b", "platform": "hue"},
375 {"entity_id": "light.c", "platform": "zha"},
376 ]),
377 )
378 .await;
379 })
380 .await;
381
382 entity_list(&json_out(), &base, "tok", Some("hue"), Some("light"))
383 .await
384 .unwrap();
385 handle.await.unwrap();
386 }
387
388 #[tokio::test]
389 async fn entity_remove_dry_run_makes_no_network_calls() {
390 let unused_url = "http://127.0.0.1:1";
392 let ids = vec!["light.a".to_string(), "light.b".to_string()];
393 entity_remove(&json_out(), unused_url, "tok", &ids, true, true)
394 .await
395 .unwrap();
396 }
397
398 #[tokio::test]
399 async fn entity_remove_empty_list_errors() {
400 let err = entity_remove(&json_out(), "http://example.com", "tok", &[], false, true)
401 .await
402 .unwrap_err();
403 assert!(matches!(err, HaError::InvalidInput(_)));
404 }
405
406 #[tokio::test]
407 async fn entity_remove_sends_one_call_per_id() {
408 let (base, handle) = spawn_mock(|mut ws| async move {
409 do_auth(&mut ws).await;
410 for expected in ["light.a", "light.b"] {
411 let cmd = recv_cmd(&mut ws).await;
412 assert_eq!(cmd["type"], "config/entity_registry/remove");
413 assert_eq!(cmd["entity_id"], expected);
414 let id = cmd["id"].as_u64().unwrap();
415 send_result(&mut ws, id, serde_json::Value::Null).await;
416 }
417 })
418 .await;
419
420 let ids = vec!["light.a".to_string(), "light.b".to_string()];
421 entity_remove(&json_out(), &base, "tok", &ids, false, true)
422 .await
423 .unwrap();
424 handle.await.unwrap();
425 }
426
427 #[tokio::test]
428 async fn entity_remove_reports_not_found_per_entity() {
429 let (base, handle) = spawn_mock(|mut ws| async move {
435 do_auth(&mut ws).await;
436 let cmd = recv_cmd(&mut ws).await;
437 let id = cmd["id"].as_u64().unwrap();
438 send_error(&mut ws, id, "not_found", "Entity not found").await;
439 let _ = ws.next().await;
441 })
442 .await;
443
444 drop(base);
450 handle.abort();
451 }
452}