Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8250ae0
feat(errors): implement the published conflict error kind
jaxzin Jul 26, 2026
ee857fc
feat(api): model PoE telemetry and last_connection on PortEntry
jaxzin Jul 26, 2026
35e300e
refactor: make enable/autoneg/is_uplink tri-state in PortEntry
jaxzin Jul 26, 2026
324cb71
feat(api): add power_cycle_port and list_all_device_ports
jaxzin Jul 26, 2026
10f9e3e
feat(ports): add top-level ports list
jaxzin Jul 26, 2026
45fb4cc
fix(ports): restore link colouring, guard PORTS_LIST, add functional …
jaxzin Jul 26, 2026
d7498ac
refactor(devices): route devices ports through the shared renderer
jaxzin Jul 26, 2026
b8e91e9
test(ports): lock in the devices ports bare-array vs ports list envel…
jaxzin Jul 26, 2026
fa0aa86
feat(ports): add ports show with full PoE telemetry
jaxzin Jul 26, 2026
3419e96
feat(ports): add ports find reverse lookup
jaxzin Jul 26, 2026
f319c23
feat(ports): add ports cycle with pre-flight guard rails
jaxzin Jul 26, 2026
96dd78d
feat(ports): prompt before power-cycling on a TTY
jaxzin Jul 26, 2026
0e8a53d
docs: document the ports command tree
jaxzin Jul 26, 2026
6dcebf2
test(ports): cover the cycle() mutation path and cycle_summary
jaxzin Jul 26, 2026
9acd530
fix(ports): terminate decline prompt line, widen confirmation_require…
jaxzin Jul 26, 2026
86c0df2
refactor(ports): boy-scout cleanup backlog (B1-B8)
jaxzin Jul 26, 2026
fb36a0f
docs: correct when ports cycle refuses
jaxzin Jul 26, 2026
d045f1c
feat(ports): show a Connected column in ports find's text output
jaxzin Jul 26, 2026
840fabd
docs(ports): note that cycle's PoE off-interval is switch-defined
jaxzin Jul 26, 2026
d881b34
fix(ports): reject cycling a PoE port not currently delivering power
jaxzin Jul 27, 2026
ef3e4e1
fix(ports): resolve find ambiguity by port occupancy, not client-name…
jaxzin Jul 27, 2026
dfd68cb
chore(ports): replace real host names and MACs in test fixtures with …
jaxzin Jul 27, 2026
14a96b1
fix(ports): pad coloured table cells by visible width, not escape byt…
jaxzin Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.



## [Unreleased]

### Added

- **ports**: new `unifi ports` command tree — `list`, `show`, `find`, and `cycle` — for working with individual switch ports instead of whole devices.
- **ports cycle**: power-cycles a single PoE port via the `devmgr` `power-cycle` command, with pre-flight checks that reject non-PoE ports, administratively-disabled PoE, and a port not currently delivering PoE (`poe_enable: false`, observed live to make the controller reject the command) without ever sending the power-cycle command (the port table is read first). Prompts for confirmation on a TTY; requires `--yes` when piped, otherwise exits 2 with `kind: confirmation_required`.
- **ports find**: resolves a MAC, an IP, or a client name (case-insensitive substring match) to the switch port(s) a device is attached to, via `port_table.last_connection`. A device that has moved between ports appears once per port, with `connected` marking its current one. A name is ambiguous only when it matches more than one device that is actually on a switch port; that returns `kind: conflict` (exit 6) listing the candidates rather than guessing. Other client records sharing the name that aren't themselves on a port (a device's WiFi interface reporting under the same name as its wired one, say) don't count toward ambiguity.
- **ports show**: exposes PoE telemetry the CLI previously discarded — `poe_mode`, `poe_class`, `poe_voltage`, `poe_current`, `poe_good` — plus `attached_mac`.
- The `conflict` error kind (exit code 6), already advertised in `unifi schema`'s error table since 0.2.2 but never emitted by any code path until now.

### Changed

- **devices ports**: `unifi devices ports <MAC>` is now an alias for `unifi ports list <MAC>`. Its JSON output gains `device_mac` and `device_name`; every previously emitted key is unchanged, and it keeps its historical bare-JSON-array shape for backward compatibility.


## [0.3.0](https://github.com/rvben/unifi-cli/compare/v0.2.3...v0.3.0) - 2026-07-09
Expand Down
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,71 @@ unifi devices locate aa:bb:cc:dd:ee:ff # Blink locate LED
unifi devices locate aa:bb:cc:dd:ee:ff --off # Stop blinking
```

### Ports

Find which switch port a device is plugged into, then power-cycle just that
port instead of rebooting the whole switch:

```bash
# Which port is my Pi on? Matches by name (case-insensitive substring),
# MAC, or IP.
unifi ports find garage-pi
unifi ports find aa:bb:cc:dd:ee:10

# Inspect it — PoE mode, class, voltage, current, and what's attached
unifi ports show aa:bb:cc:dd:ee:ff 5

# Bounce PoE on that port only — the rest of the switch is untouched
unifi ports cycle aa:bb:cc:dd:ee:ff 5
```

`ports find`'s output feeds directly into `show` and `cycle`: `device_mac`
and `port_idx` are the *switch's* MAC and port index, not the attached
device's. A name is ambiguous only when it matches more than one device
that's actually on a switch port — that returns `kind: conflict` (exit 6)
listing the candidates rather than guessing. Other client records sharing
the name (a device's WiFi interface reporting under the same name as its
wired one, say) don't cause a conflict if they're not themselves on a port.
A device that has moved between switch ports appears once per port it has
ever used, with a `connected` field distinguishing its current port from
stale history.

`ports show` exposes PoE telemetry the CLI previously discarded:
`poe_mode`, `poe_class`, `poe_voltage`, `poe_current`, `poe_good`, and the
MAC of the attached device (`attached_mac`).

`ports cycle` is destructive. On a terminal it shows what is about to lose
power and asks for confirmation; when piped it requires `--yes` and
otherwise exits 2 with `kind: confirmation_required`. It reads the port
table first, then refuses **without ever sending the power-cycle command**
when:

- the port is not PoE-capable (an SFP+ port, say) → `kind: conflict`, exit 6
- the port's PoE is administratively off → `kind: conflict`, exit 6
- the port isn't currently delivering PoE (`poe_enable: false`) → `kind: conflict`, exit 6
- the device has no such port index → `kind: not_found`, exit 4

The off interval — how long the port stays unpowered — is chosen by the
switch firmware, not by this CLI. The power-cycle command takes only the
target port, with no duration parameter, on either the legacy endpoint or
the Integration API, so the interval isn't configurable and varies by
device model and firmware version. IEEE 802.3 PoE detection timing imposes
a floor regardless: expect the port to sit dark for roughly 1-2 seconds at
minimum before power returns.

List ports for one device, or across every device:

```bash
unifi ports list aa:bb:cc:dd:ee:ff
unifi ports list --limit 20 --fields port_idx,poe_power
```

`ports list` returns the paginated `{items, total, limit, offset}` envelope
used by the other list commands. `unifi devices ports <MAC>` remains an
alias for `unifi ports list <MAC>`; it keeps its original bare-JSON-array
shape for backward compatibility, and both emit the same per-row fields,
including `device_mac` and `device_name`.

### Events

```bash
Expand Down Expand Up @@ -239,6 +304,7 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
| 3 | Authentication error (401/403) |
| 4 | Not found (404) |
| 5 | API error (server error) |
| 6 | Conflict (ambiguous match or failed precondition) |

## Development

Expand Down
19 changes: 19 additions & 0 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,18 @@ impl UnifiClient {
Ok(())
}

/// Power-cycle a single PoE port. `mac` is the **switch's** MAC, not the
/// attached device's.
pub async fn power_cycle_port(&self, mac: &str, port_idx: u32) -> Result<(), ApiError> {
let formatted = format_mac(&normalize_mac(mac));
self.post_legacy_cmd(
"devmgr",
serde_json::json!({"cmd": "power-cycle", "mac": formatted, "port_idx": port_idx}),
)
.await?;
Ok(())
}

pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
let formatted = format_mac(&normalize_mac(mac));
self.post_legacy_cmd(
Expand Down Expand Up @@ -407,6 +419,13 @@ impl UnifiClient {
.ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
}

/// Every device that reports a port table, in one request. `/stat/device`
/// already returns all devices with their port tables, so the unfiltered
/// listing costs no more than the filtered one.
pub async fn list_all_device_ports(&self) -> Result<Vec<DeviceWithPorts>, ApiError> {
self.get_legacy("/stat/device").await
}

// All clients with bandwidth data (legacy endpoint for richer stats)
pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
self.get_legacy("/stat/sta").await
Expand Down
6 changes: 3 additions & 3 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pub use client::ProtectSession;
pub use client::UnifiClient;
pub use client::error_for_status;
pub use types::{
ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LegacyClient,
LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes, format_mac,
format_uptime, normalize_mac,
ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LastConnection,
LegacyClient, LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes,
format_mac, format_uptime, normalize_mac,
};
78 changes: 78 additions & 0 deletions src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,17 @@ fn api_error_display_auth() {
assert!(display.contains("Hint:"));
}

#[test]
fn api_error_display_conflict_has_no_prefix() {
// Unlike Auth/NotFound (sentence fragments that get a prefix added),
// Conflict messages are written whole, to be read by an operator as-is.
let err = ApiError::Conflict("Port 5 on aa:bb:cc:dd:ee:ff does not support PoE.".into());
assert_eq!(
err.to_string(),
"Port 5 on aa:bb:cc:dd:ee:ff does not support PoE."
);
}

#[test]
fn api_error_display_other() {
let err = ApiError::Other("something went wrong".into());
Expand Down Expand Up @@ -812,3 +823,70 @@ fn legacy_client_clean_name_no_mac() {
let client: LegacyClient = serde_json::from_str(json).unwrap();
assert_eq!(client.clean_name(), "device ee:ff");
}

// --- PortEntry PoE telemetry ---

#[test]
fn port_entry_parses_poe_telemetry_with_string_numbers() {
// The controller returns poe_voltage/poe_current/poe_power as JSON
// strings on some firmware, the same quirk PR #3 fixed for poe_power.
let json = serde_json::json!({
"port_idx": 3,
"name": "Port 3",
"up": true,
"port_poe": true,
"poe_enable": true,
"poe_mode": "auto",
"poe_class": "Class 3",
"poe_power": "5.00",
"poe_voltage": "53.75",
"poe_current": "93.00",
"poe_good": true,
"autoneg": true,
"enable": true,
"is_uplink": false,
"stp_state": "forwarding",
"tx_errors": 0,
"rx_errors": 0,
"last_connection": {
"mac": "aa:bb:cc:dd:ee:20",
"connected": true,
"last_seen": 1783622695
}
});
let p: PortEntry = serde_json::from_value(json).expect("PortEntry must parse");
assert_eq!(p.poe_mode.as_deref(), Some("auto"));
assert_eq!(p.poe_class.as_deref(), Some("Class 3"));
assert_eq!(p.poe_voltage, Some(53.75));
assert_eq!(p.poe_current, Some(93.00));
assert_eq!(p.poe_good, Some(true));
assert_eq!(p.stp_state.as_deref(), Some("forwarding"));
assert_eq!(p.autoneg, Some(true));
assert_eq!(p.enable, Some(true));
assert_eq!(p.is_uplink, Some(false));
let lc = p.last_connection.expect("last_connection present");
assert_eq!(lc.mac.as_deref(), Some("aa:bb:cc:dd:ee:20"));
assert_eq!(lc.connected, Some(true));
}

#[test]
fn port_entry_tolerates_absent_last_connection() {
// A port nothing has ever linked to omits last_connection entirely.
// This is exactly how the empty test-target port was identified.
let json = serde_json::json!({
"port_idx": 4,
"name": "Port 4",
"up": false,
"port_poe": true,
"poe_enable": false,
"poe_mode": "auto"
});
let p: PortEntry = serde_json::from_value(json).expect("PortEntry must parse");
assert!(p.last_connection.is_none());
assert_eq!(p.poe_voltage, None);
assert!(!p.up);
// Absent tri-state keys must read as unknown, never as a confident "no".
assert_eq!(p.enable, None, "absent enable must not read as disabled");
assert_eq!(p.autoneg, None);
assert_eq!(p.is_uplink, None);
}
43 changes: 42 additions & 1 deletion src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,43 @@ pub struct PortEntry {
pub poe_power: Option<f64>,
#[serde(default)]
pub port_poe: bool,
/// "auto", "off", "passthrough", "passive24v". Absent on some firmware.
pub poe_mode: Option<String>,
pub poe_class: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
pub poe_voltage: Option<f64>,
#[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
pub poe_current: Option<f64>,
pub poe_good: Option<bool>,
/// Auto-negotiation state. `Option`, not a defaulted bool, like `enable`
/// and `is_uplink` below: a firmware that omits this key must not be
/// reported as "auto-negotiation off". Matches `poe_good` above; contrast
/// `up`/`poe_enable`, where an absent key genuinely does mean false.
pub autoneg: Option<bool>,
/// Administrative enable state. Same tri-state rationale as `autoneg`: an
/// absent key must not be reported as "port administratively disabled".
pub enable: Option<bool>,
/// Whether this port is the switch's uplink. Same tri-state rationale as
/// `autoneg`: an absent key must not be reported as "not an uplink".
pub is_uplink: Option<bool>,
pub stp_state: Option<String>,
pub tx_errors: Option<u64>,
pub rx_errors: Option<u64>,
/// Absent entirely on a port nothing has linked to within retention.
pub last_connection: Option<LastConnection>,
pub tx_bytes: Option<u64>,
pub rx_bytes: Option<u64>,
}

/// The device most recently seen on a port. `connected` distinguishes a live
/// attachment from a stale record of a device that has since moved.
#[derive(Debug, Deserialize)]
pub struct LastConnection {
pub mac: Option<String>,
pub connected: Option<bool>,
pub last_seen: Option<u64>,
}

fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
Expand Down Expand Up @@ -485,9 +518,16 @@ pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
#[derive(Debug)]
pub enum ApiError {
Http(reqwest::Error),
Api { status: u16, message: String },
Api {
status: u16,
message: String,
},
NotFound(String),
Auth(String),
/// A request that cannot succeed against the resource's current state,
/// rejected locally before any HTTP call. Published by `unifi schema`
/// as kind `conflict`, exit code 6.
Conflict(String),
Other(String),
}

Expand Down Expand Up @@ -570,6 +610,7 @@ impl fmt::Display for ApiError {
"\n Hint: Check your API key. Generate one in UniFi Settings > API"
)
}
ApiError::Conflict(msg) => write!(f, "{msg}"),
ApiError::Other(msg) => write!(f, "{msg}"),
}
}
Expand Down
Loading