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