Skip to main content

unifi_cli/commands/
devices.rs

1use owo_colors::OwoColorize;
2
3use crate::api::{Device, UnifiClient, format_bytes, format_mac, format_uptime};
4use crate::output::{OutputConfig, use_color};
5
6pub struct Pagination {
7    pub limit: usize,
8    pub offset: usize,
9    pub fields: Option<String>,
10}
11
12fn render_devices(devices: &[Device], out: &OutputConfig) {
13    if out.is_json() {
14        out.print_data(
15            &serde_json::to_string_pretty(
16                &devices
17                    .iter()
18                    .map(|d| {
19                        serde_json::json!({
20                            "name": d.name,
21                            "model": d.model,
22                            "mac": d.mac_address,
23                            "ip": d.ip_address,
24                            "state": d.state,
25                            "firmware": d.firmware_version,
26                        })
27                    })
28                    .collect::<Vec<_>>(),
29            )
30            .expect("failed to serialize JSON"),
31        );
32    } else {
33        let color = use_color();
34
35        // Compute dynamic column widths from data
36        let col = |min: usize, label_len: usize, vals: Vec<usize>| -> usize {
37            vals.into_iter().max().unwrap_or(0).max(label_len).max(min) + 2
38        };
39        let names: Vec<&str> = devices
40            .iter()
41            .map(|d| d.name.as_deref().unwrap_or("-"))
42            .collect();
43        let models: Vec<&str> = devices
44            .iter()
45            .map(|d| d.model.as_deref().unwrap_or("-"))
46            .collect();
47
48        let name_w = col(4, 4, names.iter().map(|n| n.len()).collect());
49        let model_w = col(5, 5, models.iter().map(|m| m.len()).collect());
50        let total_w = name_w + model_w + 19 + 15 + 10 + 10;
51
52        let header = format!(
53            "{:<name_w$} {:<model_w$} {:<19} {:<15} {:<10} {}",
54            "Name", "Model", "MAC", "IP", "State", "Firmware"
55        );
56        if color {
57            println!("{}", header.bold());
58            println!("{}", "-".repeat(total_w).dimmed());
59        } else {
60            println!("{header}");
61            println!("{}", "-".repeat(total_w));
62        }
63
64        for d in devices {
65            let name = d.name.as_deref().unwrap_or("-");
66            let model = d.model.as_deref().unwrap_or("-");
67            let mac = d
68                .mac_address
69                .as_deref()
70                .map(format_mac)
71                .unwrap_or_else(|| "-".into());
72            let ip = d.ip_address.as_deref().unwrap_or("-");
73            let state = d.state.as_deref().unwrap_or("-");
74            let fw = d.firmware_version.as_deref().unwrap_or("-");
75            let name_pad = name_w - 1;
76            let model_pad = model_w;
77
78            if color {
79                println!(
80                    " {:<name_pad$} {:<model_pad$} {:<19} {:<15} {:<10} {}",
81                    name.bold(),
82                    model,
83                    mac.dimmed(),
84                    ip,
85                    state,
86                    fw,
87                );
88            } else {
89                println!(
90                    " {:<name_pad$} {:<model_pad$} {:<19} {:<15} {:<10} {}",
91                    name, model, mac, ip, state, fw
92                );
93            }
94        }
95    }
96    out.print_message(&format!("\n{} devices", devices.len()));
97}
98
99pub async fn list(
100    client: &mut UnifiClient,
101    out: OutputConfig,
102    watch: Option<u64>,
103    pagination: Pagination,
104) -> Result<(), Box<dyn std::error::Error>> {
105    if let Some(interval) = watch {
106        use crossterm::execute;
107        use crossterm::terminal::EnterAlternateScreen;
108
109        let mut stdout = std::io::stdout();
110        execute!(stdout, EnterAlternateScreen)?;
111
112        loop {
113            execute!(stdout, crossterm::cursor::MoveTo(0, 0))?;
114            execute!(
115                stdout,
116                crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
117            )?;
118            eprintln!("Every {interval}s | devices list (press Ctrl+C to exit)\n");
119            match client.list_devices().await {
120                Ok(devices) => {
121                    render_devices(&devices, &out);
122                }
123                Err(e) => {
124                    eprintln!("Error: {e}");
125                }
126            }
127            tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
128        }
129    } else {
130        let devices = client.list_devices().await?;
131        let total = devices.len();
132        let paginated: Vec<Device> = devices
133            .into_iter()
134            .skip(pagination.offset)
135            .take(pagination.limit)
136            .collect();
137        if out.is_json() {
138            let items: Vec<serde_json::Value> = paginated
139                .iter()
140                .map(|d| {
141                    let mut obj = serde_json::json!({
142                        "name": d.name,
143                        "model": d.model,
144                        "mac": d.mac_address,
145                        "ip": d.ip_address,
146                        "state": d.state,
147                        "firmware": d.firmware_version,
148                    });
149                    if let Some(ref fields_str) = pagination.fields {
150                        let keep: Vec<&str> = fields_str.split(',').map(str::trim).collect();
151                        let map = obj.as_object_mut().unwrap();
152                        map.retain(|k, _| keep.contains(&k.as_str()));
153                    }
154                    obj
155                })
156                .collect();
157            out.print_data(
158                &serde_json::to_string_pretty(&serde_json::json!({
159                    "items": items,
160                    "total": total,
161                    "limit": pagination.limit,
162                    "offset": pagination.offset,
163                }))
164                .expect("failed to serialize JSON"),
165            );
166        } else {
167            render_devices(&paginated, &out);
168        }
169        Ok(())
170    }
171}
172
173pub async fn show(
174    client: &UnifiClient,
175    mac: &str,
176    out: OutputConfig,
177) -> Result<(), Box<dyn std::error::Error>> {
178    let d = client.get_device_detail(mac).await?;
179
180    if out.is_json() {
181        out.print_data(&serde_json::to_string_pretty(&serde_json::json!({
182            "name": d.name,
183            "model": d.model,
184            "mac": d.mac,
185            "ip": d.ip,
186            "state": d.state_str(),
187            "version": d.version,
188            "uptime": d.uptime,
189            "num_sta": d.num_sta,
190        }))?);
191        return Ok(());
192    }
193
194    let color = use_color();
195    let label = |l: &str| -> String {
196        if color {
197            format!("{}", l.dimmed())
198        } else {
199            l.to_string()
200        }
201    };
202
203    let name = d.name.as_deref().unwrap_or("Device");
204    if color {
205        println!("{}", name.bold());
206    } else {
207        println!("{name}");
208    }
209
210    println!(
211        "  {}  {}",
212        label("Model:   "),
213        d.model.as_deref().unwrap_or("-")
214    );
215    println!(
216        "  {}  {}",
217        label("MAC:     "),
218        d.mac
219            .as_deref()
220            .map(format_mac)
221            .unwrap_or_else(|| "-".into())
222    );
223    println!(
224        "  {}  {}",
225        label("IP:      "),
226        d.ip.as_deref().unwrap_or("-")
227    );
228    println!("  {}  {}", label("State:   "), d.state_str());
229
230    if let Some(ref v) = d.version {
231        println!("  {}  {v}", label("Firmware:"));
232    }
233    if let Some(uptime) = d.uptime {
234        println!("  {}  {}", label("Uptime:  "), format_uptime(uptime));
235    }
236    if let Some(num_sta) = d.num_sta {
237        println!("  {}  {num_sta}", label("Clients: "));
238    }
239
240    Ok(())
241}
242
243pub async fn restart(
244    client: &UnifiClient,
245    mac: &str,
246    out: OutputConfig,
247) -> Result<(), Box<dyn std::error::Error>> {
248    client.restart_device(mac).await?;
249    out.print_result(
250        &serde_json::json!({"status": "ok", "action": "restart", "mac": format_mac(mac)}),
251        &format!("Restarting {}", format_mac(mac)),
252    );
253    Ok(())
254}
255
256pub async fn ports(
257    client: &UnifiClient,
258    mac: &str,
259    out: OutputConfig,
260) -> Result<(), Box<dyn std::error::Error>> {
261    let device = client.get_device_ports(mac).await?;
262
263    if device.port_table.is_empty() {
264        out.print_message("No port table available for this device (not a switch or router)");
265        if out.is_json() {
266            out.print_data("[]");
267        }
268        return Ok(());
269    }
270
271    if out.is_json() {
272        out.print_data(
273            &serde_json::to_string_pretty(
274                &device
275                    .port_table
276                    .iter()
277                    .map(|p| {
278                        serde_json::json!({
279                            "port_idx": p.port_idx,
280                            "name": p.name,
281                            "media": p.media,
282                            "up": p.up,
283                            "speed": p.speed,
284                            "full_duplex": p.full_duplex,
285                            "poe_enable": p.poe_enable,
286                            "poe_power": p.poe_power,
287                            "port_poe": p.port_poe,
288                            "tx_bytes": p.tx_bytes,
289                            "rx_bytes": p.rx_bytes,
290                        })
291                    })
292                    .collect::<Vec<_>>(),
293            )
294            .expect("failed to serialize JSON"),
295        );
296    } else {
297        let device_label = device
298            .name
299            .as_deref()
300            .unwrap_or(device.model.as_deref().unwrap_or("Device"));
301        out.print_message(&format!("Ports for {device_label}:\n"));
302
303        let color = use_color();
304        let header = format!(
305            "{:<6} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
306            "Port", "Name", "Link", "Speed", "PoE", "TX", "RX"
307        );
308        if color {
309            println!("{}", header.bold());
310            println!("{}", "-".repeat(70).dimmed());
311        } else {
312            println!("{header}");
313            println!("{}", "-".repeat(70));
314        }
315
316        for p in &device.port_table {
317            let port = p
318                .port_idx
319                .map(|i| i.to_string())
320                .unwrap_or_else(|| "-".into());
321            let name = p.name.as_deref().unwrap_or("-");
322            let link = if p.up { "up" } else { "down" };
323            let speed = if p.up {
324                match p.speed {
325                    Some(s) => {
326                        let duplex = if p.full_duplex { "FD" } else { "HD" };
327                        format!("{s}{duplex}")
328                    }
329                    None => "up".into(),
330                }
331            } else {
332                "down".into()
333            };
334            let poe = if p.poe_enable {
335                match p.poe_power {
336                    Some(w) if w > 0.0 => format!("{w:.1}W"),
337                    _ => "on".into(),
338                }
339            } else if p.port_poe {
340                "off".into()
341            } else {
342                "-".into()
343            };
344            let tx = p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into());
345            let rx = p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into());
346
347            if color {
348                let link_display = if p.up {
349                    format!("{}", "up".green())
350                } else {
351                    format!("{}", "down".dimmed())
352                };
353                println!(
354                    " {:<5} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
355                    port, name, link_display, speed, poe, tx, rx
356                );
357            } else {
358                println!(
359                    " {:<5} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
360                    port, name, link, speed, poe, tx, rx
361                );
362            }
363        }
364    }
365    out.print_message(&format!("\n{} ports", device.port_table.len()));
366    Ok(())
367}
368
369pub async fn upgrade(
370    client: &UnifiClient,
371    mac: &str,
372    out: OutputConfig,
373) -> Result<(), Box<dyn std::error::Error>> {
374    client.upgrade_device(mac).await?;
375    out.print_result(
376        &serde_json::json!({"status": "ok", "action": "upgrade", "mac": format_mac(mac)}),
377        &format!("Upgrading firmware on {}", format_mac(mac)),
378    );
379    Ok(())
380}
381
382pub async fn locate(
383    client: &UnifiClient,
384    mac: &str,
385    off: bool,
386    out: OutputConfig,
387) -> Result<(), Box<dyn std::error::Error>> {
388    client.locate_device(mac, !off).await?;
389    let action = if off { "locate_off" } else { "locate_on" };
390    let msg = if off {
391        format!("Stopped locating {}", format_mac(mac))
392    } else {
393        format!("Locating {} (LED blinking)", format_mac(mac))
394    };
395    out.print_result(
396        &serde_json::json!({"status": "ok", "action": action, "mac": format_mac(mac)}),
397        &msg,
398    );
399    Ok(())
400}