unifi-cli 0.1.5

CLI for UniFi Network controller
Documentation
use owo_colors::OwoColorize;

use crate::api::{Device, UnifiClient, format_bytes, format_mac, format_uptime};
use crate::output::{OutputConfig, use_color};

fn render_devices(devices: &[Device], out: &OutputConfig) {
    if out.json {
        out.print_data(
            &serde_json::to_string_pretty(
                &devices
                    .iter()
                    .map(|d| {
                        serde_json::json!({
                            "name": d.name,
                            "model": d.model,
                            "mac": d.mac_address,
                            "ip": d.ip_address,
                            "state": d.state,
                            "firmware": d.firmware_version,
                        })
                    })
                    .collect::<Vec<_>>(),
            )
            .expect("failed to serialize JSON"),
        );
    } else {
        let color = use_color();

        // Compute dynamic column widths from data
        let col = |min: usize, label_len: usize, vals: Vec<usize>| -> usize {
            vals.into_iter().max().unwrap_or(0).max(label_len).max(min) + 2
        };
        let names: Vec<&str> = devices
            .iter()
            .map(|d| d.name.as_deref().unwrap_or("-"))
            .collect();
        let models: Vec<&str> = devices
            .iter()
            .map(|d| d.model.as_deref().unwrap_or("-"))
            .collect();

        let name_w = col(4, 4, names.iter().map(|n| n.len()).collect());
        let model_w = col(5, 5, models.iter().map(|m| m.len()).collect());
        let total_w = name_w + model_w + 19 + 15 + 10 + 10;

        let header = format!(
            "{:<name_w$} {:<model_w$} {:<19} {:<15} {:<10} {}",
            "Name", "Model", "MAC", "IP", "State", "Firmware"
        );
        if color {
            println!("{}", header.bold());
            println!("{}", "-".repeat(total_w).dimmed());
        } else {
            println!("{header}");
            println!("{}", "-".repeat(total_w));
        }

        for d in devices {
            let name = d.name.as_deref().unwrap_or("-");
            let model = d.model.as_deref().unwrap_or("-");
            let mac = d
                .mac_address
                .as_deref()
                .map(format_mac)
                .unwrap_or_else(|| "-".into());
            let ip = d.ip_address.as_deref().unwrap_or("-");
            let state = d.state.as_deref().unwrap_or("-");
            let fw = d.firmware_version.as_deref().unwrap_or("-");
            let name_pad = name_w - 1;
            let model_pad = model_w;

            if color {
                println!(
                    " {:<name_pad$} {:<model_pad$} {:<19} {:<15} {:<10} {}",
                    name.bold(),
                    model,
                    mac.dimmed(),
                    ip,
                    state,
                    fw,
                );
            } else {
                println!(
                    " {:<name_pad$} {:<model_pad$} {:<19} {:<15} {:<10} {}",
                    name, model, mac, ip, state, fw
                );
            }
        }
    }
    out.print_message(&format!("\n{} devices", devices.len()));
}

pub async fn list(
    client: &mut UnifiClient,
    out: OutputConfig,
    watch: Option<u64>,
) -> Result<(), Box<dyn std::error::Error>> {
    if let Some(interval) = watch {
        use crossterm::execute;
        use crossterm::terminal::EnterAlternateScreen;

        let mut stdout = std::io::stdout();
        execute!(stdout, EnterAlternateScreen)?;

        loop {
            execute!(stdout, crossterm::cursor::MoveTo(0, 0))?;
            execute!(
                stdout,
                crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
            )?;
            eprintln!("Every {interval}s | devices list (press Ctrl+C to exit)\n");
            match client.list_devices().await {
                Ok(devices) => {
                    render_devices(&devices, &out);
                }
                Err(e) => {
                    eprintln!("Error: {e}");
                }
            }
            tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
        }
    } else {
        let devices = client.list_devices().await?;
        render_devices(&devices, &out);
        Ok(())
    }
}

pub async fn show(
    client: &UnifiClient,
    mac: &str,
    out: OutputConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    let d = client.get_device_detail(mac).await?;

    if out.json {
        out.print_data(&serde_json::to_string_pretty(&serde_json::json!({
            "name": d.name,
            "model": d.model,
            "mac": d.mac,
            "ip": d.ip,
            "state": d.state_str(),
            "version": d.version,
            "uptime": d.uptime,
            "num_sta": d.num_sta,
        }))?);
        return Ok(());
    }

    let color = use_color();
    let label = |l: &str| -> String {
        if color {
            format!("{}", l.dimmed())
        } else {
            l.to_string()
        }
    };

    let name = d.name.as_deref().unwrap_or("Device");
    if color {
        println!("{}", name.bold());
    } else {
        println!("{name}");
    }

    println!(
        "  {}  {}",
        label("Model:   "),
        d.model.as_deref().unwrap_or("-")
    );
    println!(
        "  {}  {}",
        label("MAC:     "),
        d.mac
            .as_deref()
            .map(format_mac)
            .unwrap_or_else(|| "-".into())
    );
    println!(
        "  {}  {}",
        label("IP:      "),
        d.ip.as_deref().unwrap_or("-")
    );
    println!("  {}  {}", label("State:   "), d.state_str());

    if let Some(ref v) = d.version {
        println!("  {}  {v}", label("Firmware:"));
    }
    if let Some(uptime) = d.uptime {
        println!("  {}  {}", label("Uptime:  "), format_uptime(uptime));
    }
    if let Some(num_sta) = d.num_sta {
        println!("  {}  {num_sta}", label("Clients: "));
    }

    Ok(())
}

pub async fn restart(
    client: &UnifiClient,
    mac: &str,
    out: OutputConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    client.restart_device(mac).await?;
    out.print_result(
        &serde_json::json!({"status": "ok", "action": "restart", "mac": format_mac(mac)}),
        &format!("Restarting {}", format_mac(mac)),
    );
    Ok(())
}

pub async fn ports(
    client: &UnifiClient,
    mac: &str,
    out: OutputConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    let device = client.get_device_ports(mac).await?;

    if device.port_table.is_empty() {
        out.print_message("No port table available for this device (not a switch or router)");
        if out.json {
            out.print_data("[]");
        }
        return Ok(());
    }

    if out.json {
        out.print_data(
            &serde_json::to_string_pretty(
                &device
                    .port_table
                    .iter()
                    .map(|p| {
                        serde_json::json!({
                            "port_idx": p.port_idx,
                            "name": p.name,
                            "media": p.media,
                            "up": p.up,
                            "speed": p.speed,
                            "full_duplex": p.full_duplex,
                            "poe_enable": p.poe_enable,
                            "poe_power": p.poe_power,
                            "port_poe": p.port_poe,
                            "tx_bytes": p.tx_bytes,
                            "rx_bytes": p.rx_bytes,
                        })
                    })
                    .collect::<Vec<_>>(),
            )
            .expect("failed to serialize JSON"),
        );
    } else {
        let device_label = device
            .name
            .as_deref()
            .unwrap_or(device.model.as_deref().unwrap_or("Device"));
        out.print_message(&format!("Ports for {device_label}:\n"));

        let color = use_color();
        let header = format!(
            "{:<6} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
            "Port", "Name", "Link", "Speed", "PoE", "TX", "RX"
        );
        if color {
            println!("{}", header.bold());
            println!("{}", "-".repeat(70).dimmed());
        } else {
            println!("{header}");
            println!("{}", "-".repeat(70));
        }

        for p in &device.port_table {
            let port = p
                .port_idx
                .map(|i| i.to_string())
                .unwrap_or_else(|| "-".into());
            let name = p.name.as_deref().unwrap_or("-");
            let link = if p.up { "up" } else { "down" };
            let speed = if p.up {
                match p.speed {
                    Some(s) => {
                        let duplex = if p.full_duplex { "FD" } else { "HD" };
                        format!("{s}{duplex}")
                    }
                    None => "up".into(),
                }
            } else {
                "down".into()
            };
            let poe = if p.poe_enable {
                match p.poe_power {
                    Some(w) if w > 0.0 => format!("{w:.1}W"),
                    _ => "on".into(),
                }
            } else if p.port_poe {
                "off".into()
            } else {
                "-".into()
            };
            let tx = p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into());
            let rx = p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into());

            if color {
                let link_display = if p.up {
                    format!("{}", "up".green())
                } else {
                    format!("{}", "down".dimmed())
                };
                println!(
                    " {:<5} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
                    port, name, link_display, speed, poe, tx, rx
                );
            } else {
                println!(
                    " {:<5} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
                    port, name, link, speed, poe, tx, rx
                );
            }
        }
    }
    out.print_message(&format!("\n{} ports", device.port_table.len()));
    Ok(())
}

pub async fn upgrade(
    client: &UnifiClient,
    mac: &str,
    out: OutputConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    client.upgrade_device(mac).await?;
    out.print_result(
        &serde_json::json!({"status": "ok", "action": "upgrade", "mac": format_mac(mac)}),
        &format!("Upgrading firmware on {}", format_mac(mac)),
    );
    Ok(())
}

pub async fn locate(
    client: &UnifiClient,
    mac: &str,
    off: bool,
    out: OutputConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    client.locate_device(mac, !off).await?;
    let action = if off { "locate_off" } else { "locate_on" };
    let msg = if off {
        format!("Stopped locating {}", format_mac(mac))
    } else {
        format!("Locating {} (LED blinking)", format_mac(mac))
    };
    out.print_result(
        &serde_json::json!({"status": "ok", "action": action, "mac": format_mac(mac)}),
        &msg,
    );
    Ok(())
}