Skip to content

feat(ports): a ports command tree with single-port PoE power-cycling - #4

Merged
rvben merged 23 commits into
rvben:mainfrom
jaxzin:feat/ports-command
Aug 10, 2026
Merged

feat(ports): a ports command tree with single-port PoE power-cycling#4
rvben merged 23 commits into
rvben:mainfrom
jaxzin:feat/ports-command

Conversation

@jaxzin

@jaxzin jaxzin commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What this adds

A ports command tree for inspecting switch ports and power-cycling a single PoE port.

# which port is my Pi on?
unifi ports find allsky

# bounce PoE on just that port — the rest of the switch is untouched
unifi ports cycle 8c:ed:e1:b0:74:e2 5

Today the only write commands under devices are restart (which reboots the whole switch, bouncing every attached device), locate, and upgrade. Recovering one wedged headless device — a Pi, a camera, an AP — means disrupting everything else on that switch, or leaving the CLI for the controller UI. The underlying API call already exists; this exposes it.

Four subcommands:

ports list [MAC] one device's ports, or every device's. Paginated, --fields, --live
ports show <MAC> <PORT> single-port detail, including PoE telemetry the CLI previously discarded
ports find <MAC|IP|NAME> which switch port a device is attached to
ports cycle <MAC> <PORT> power-cycle one PoE port

find is what makes cycle usable: cycle needs the switch's MAC and a port_idx, but the operator knows only the device. The mapping is already in port_table[].last_connection.mac — nothing new is fetched.

Design notes

I'd rather explain these up front than have you reverse-engineer them from the diff.

Why a top-level ports noun rather than devices ports cycle. Nesting under the existing devices ports is non-breaking at the CLI level — clap's subcommand_negates_reqs keeps the required MAC positional working, and I verified that. But walk_commands() in src/schema.rs only emits leaf entries, so giving devices ports a child would silently drop it, and its output_fields, from unifi schema. The command would still work when typed; it would just vanish from the machine-readable contract. Fixing that means changing the schema generator's flattening convention, which felt like the wrong thing to bundle into a feature PR for someone else's repo.

devices ports <MAC> is preserved as an alias for ports list <MAC>, routed through the same renderer so the two can't drift. It keeps its bare JSON array shape, while ports list uses the {items, total, limit, offset} envelope your other paginated list commands use. Same fields, different container, and the difference is recorded in the schema note. Its JSON gains exactly two keys — device_mac, device_name — and loses none. There's a committed regression test that fails if either envelope changes or the key sets diverge.

A pre-existing alignment bug, fixed. Your port tables mis-align whenever colour is on. The coloured cell is "\x1b[32mup\x1b[39m" — 13 bytes for 2 visible characters — and {:<6} counts the escape bytes, decides the string already exceeds the width, and emits no padding at all. So up runs straight into the speed column at a TTY, while piped output is fine. devices ports has always done this. I only noticed because my new Connected column inherited it. Cells are now padded by visible width, which fixes devices ports, ports list and ports find together. The uncoloured path is untouched and its existing tests pass unmodified. (1 ports is now 1 port, too.)

Guard rails run before any HTTP. ports cycle refuses a port that isn't PoE-capable, and one whose poe_mode is explicitly "off", rather than firing a no-op at the controller. An absent poe_mode proceeds — the field isn't guaranteed across firmware, and a missing value shouldn't block a legitimate cycle on a port that already passed the port_poe check.

The conflict error kind. unifi schema has advertised {"kind": "conflict", "exit_code": 6, "retryable": false} since the clispec v0.2 upgrade, but nothing in the source ever emitted it — there was no exit-code constant and no ApiError variant. The guard rails above are its first real users, so this PR implements the kind you'd already published rather than adding a new one. The error table itself is unchanged.

A gap I did not fix, deliberately. require_confirmation() never actually prompts — it only hard-errors when stdin isn't a TTY and --yes is absent. So on a terminal, unifi devices restart <mac> reboots a switch immediately, even though the global --yes help text reads "Skip confirmation prompt for destructive commands." The prompt it offers to skip doesn't exist.

I added a real prompt to ports cycle only, in its own commit (the last one), so you can drop or reshape it independently. That does leave ports cycle prompting while its destructive siblings don't. Retrofitting prompts onto the other six commands changes their interactive behaviour, so that's your call, not mine — happy to send it as a separate PR if you want it.

Validation

573 tests, make check clean. Tested against a live UniFi controller (UCG-Max, UniFi OS) across a USW Pro XG 8 PoE, a USW Lite 16 PoE and a USW Lite 8 PoE.

Live testing changed the code twice. Both changes are in this branch.

1. It found a missing guard rail. I fired a cycle at a port that passed both of my checks — port_poe: true, poe_mode: "auto", nothing attached — and the controller rejected it:

HTTP 400  {"meta":{"rc":"error","port":4,"msg":"api.err.InvalidTargetPort"}}

which surfaced as kind: api_error, exit 5 — and your schema advertises api_error as "retryable": true, so an agent following the contract would retry something that can never succeed. The port was PoE-capable but not delivering (poe_enable: false). There is now a third guard rail for that, returning conflict locally:

$ unifi ports cycle 74:ac:b9:ec:b4:5e 4
{"error":{"kind":"conflict","message":"Port 4 on 74:ac:b9:ec:b4:5e is not currently
 delivering PoE (poe_enable=false), so there is no power to cycle."}}   # exit 6

I should be precise about what that rests on: it is a fact that the command was rejected on a port passing both original checks, and an inference that poe_enable is the controller's precondition — it was the only differing attribute. I could not confirm it by finding a port where a cycle succeeds, for the reason below.

2. It found ports find answering the wrong question. unifi ports find allsky — my own README example — returned conflict, because that name matches two client records: a Pi's wired and wireless interfaces, MACs one bit apart. Only the wired one is on a switch port. The ambiguity check was correct about clients but find is about ports. It now conflicts only when more than one candidate is actually on a port. On this network bedroom substring-matches 13 client records and exactly one is on a port; the old code would have listed all 13.

Verified live

non-PoE port (SFP+) conflict, exit 6
PoE administratively off conflict, exit 6
PoE-capable but not delivering conflict, exit 6
no such port index not_found, exit 4
piped without --yes confirmation_required, exit 2
TTY prompt, declined confirmation_required, exit 2, no request sent
ports find by name / MAC / IP correct port returned
ports find, nothing on a port not_found, exit 4
ports list unfiltered 49 ports across the fleet
devices ports <MAC> bare array, 13 keys, all originals intact

NOT verified live

The successful power-cycle. It is covered by mock-server tests and never fired successfully at a real controller.

The reason is structural, not laziness. I restricted live cycling to a port with nothing attached. But the controller appears to require the port to be actively delivering power — and I swept the whole network: all 11 ports delivering PoE have a live device drawing that power. "Powered but empty" is close to a contradiction, so under that restriction there was no valid target. Bouncing a live device to get a better test result wasn't a trade I was willing to make on someone's home network.

If you have a spare PoE port with a device you don't mind bouncing, that's the one gap worth closing. The mutation path itself is covered by mock-server tests asserting the exact request body, and by tests that assert no HTTP write occurs on each refusal path.

Also not verified live: the genuine ports find conflict (two candidates both on ports). No name on this network currently produces one, so it's covered by a fixture.

Commits

Structured so you can take part of this and leave the rest.

Foundation — the error kind you'd already published, and data the controller was already sending:

  • 8250ae0 implement the published conflict error kind
  • ee857fc model PoE telemetry and last_connection on PortEntry
  • 35e300e make enable/autoneg/is_uplink tri-state in PortEntry
  • 324cb71 add power_cycle_port and list_all_device_ports

Read-only commands:

  • 10f9e3e add top-level ports list
  • 45fb4cc restore link colouring, guard PORTS_LIST, add functional tests
  • d7498ac route devices ports through the shared renderer
  • b8e91e9 lock in the devices ports bare-array vs ports list envelope split
  • fa0aa86 add ports show with full PoE telemetry
  • 3419e96 add ports find reverse lookup
  • d045f1c show a Connected column in ports find's text output
  • ef3e4e1 resolve find ambiguity by port occupancy, not client-name count

The destructive command — these two are separable; take the first and drop the second if you'd rather not have a prompt its siblings lack:

  • f319c23 add ports cycle with pre-flight guard rails
  • 96dd78d prompt before power-cycling on a TTY

Found by hardware, not by design — the third guard rail exists because the controller rejected a cycle my first two checks allowed:

  • d881b34 reject cycling a PoE port not currently delivering power

Tests, docs, cleanup:

  • 6dcebf2 cover the cycle() mutation path and cycle_summary
  • 9acd530 terminate decline prompt line, widen confirmation_required doc
  • 86c0df2 boy-scout cleanup backlog (B1-B8)
  • 0e8a53d document the ports command tree
  • fb36a0f correct when ports cycle refuses
  • 840fabd note that cycle's PoE off-interval is switch-defined
  • dfd68cb replace real host names and MACs in test fixtures with generic placeholders

15 files, +2756 / -101.

Not included, on purpose

Two smaller pre-existing things I noticed but didn't touch, since they're yours to decide: ApiError isn't #[non_exhaustive], so adding a variant is technically breaking for library consumers (making it non-exhaustive is itself breaking, so it's a real fork in the road); and cargo warns on every build about the duplicate unifi / unifi-cli bin targets.

On scope

This is a bigger surface than I'd normally open unannounced, and it's your repo — if you'd rather it were smaller, split across several PRs, spelled differently, or not merged at all, say so and I'll rework it. I'm not attached to the shape, only to the capability.

🤖 Generated with Claude Code

jaxzin and others added 23 commits July 26, 2026 21:08
unifi schema has advertised {kind: conflict, exit_code: 6} since the
clispec v0.2 upgrade, but nothing emitted it and no exit-code constant
existed. Adds ApiError::Conflict and wires it into both error mappers.
The controller already returns poe_mode, poe_class, poe_voltage,
poe_current, poe_good and last_connection; all were discarded. Voltage
and current reuse the string-or-number deserializer, since the same
firmware that stringifies poe_power stringifies these too.
Change three fields from defaulted bool to Option<bool> to distinguish
"firmware didn't send the field" from "field is false". This prevents
misreporting a port as administratively disabled when the controller
simply omitted the key. Matches the existing tri-state pattern used by
poe_good; contrasts with up/poe_enable/port_poe, where absent genuinely
means false.

Fixes review finding on Task 2: serde(default) on bool conflates missing
keys with false, which is incorrect for enable and affects operator
visibility in ports show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
power_cycle_port posts cmd=power-cycle to devmgr with the switch MAC and
port_idx. The mock test asserts the exact body so the port_idx key and
MAC normalisation are both pinned.
Lists one device's ports or every device's, in a single /stat/device
request either way. Rows carry device_mac/device_name so the unfiltered
listing is meaningful; the filtered table renders exactly as before.
…tests

- render_text now colours the Link column (green up / dimmed down) in both
  the filtered and unfiltered listings, matching devices::ports exactly so
  the upcoming `devices ports <MAC>` alias renders unchanged.
- Add PORTS_LIST to fields.rs's every_table_has_unique_field_names and
  every_field_declares_a_json_type self-checks, and allow "number" as a
  valid field type (poe_power already uses it in schema.rs).
- Add unit tests for collect_rows (flattening, empty-table skipping,
  device_mac/device_name fallback), row_json (key parity with
  fields::PORTS_LIST), and project (filtering and no-op on None), plus a
  wiremock-backed test asserting `ports list` pagination reports the full
  total alongside a truncated items page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
devices ports <MAC> is now an alias for ports list <MAC>. It gains
device_mac/device_name and keeps its bare-array shape; the schema note
records why the two spellings differ in envelope but not in fields.
…ope split

devices_ports_and_ports_list_are_the_same_command only checks the exit
code, and the four existing devices_ports_* tests call
commands::devices::ports in-process without ever capturing or parsing
stdout, so nothing in the suite would catch a "consistency" refactor
that wrapped devices ports in the {items,total,limit,offset} envelope.

Add devices_ports_bare_array_vs_ports_list_envelope, following the
CARGO_BIN_EXE_unifi + wiremock pattern already used by
ports_list_pagination_reports_full_total_and_truncated_items: it spawns
the real binary, parses actual stdout as JSON, and asserts devices
ports is a bare array while ports list is the envelope, with both
sharing the same per-row key set.
Surfaces poe_mode, poe_class, voltage, current and the attached device
MAC, none of which the CLI previously exposed.

Also adds integration coverage in tests/mock_server.rs: JSON output
cross-checked against the published schema's output_fields, tri-state
fields (autoneg/enable/is_uplink/poe_good/attached_mac) serializing as
null rather than false when firmware omits them, and the not-found
exit code that `ports cycle` will depend on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers which switch port a device is plugged into, using
port_table.last_connection.mac. Accepts MAC, IP or client name;
ambiguous names return conflict with the candidates rather than
guessing. Stale records are returned with connected=false, sorted last.
Rejects non-PoE ports and administratively-disabled PoE before any HTTP
call, so a doomed command never reaches the controller. Registered as
mutating: true so agents do not auto-approve it.
require_confirmation only hard-errors when piped; it never prompted,
despite --yes documenting itself as skipping a prompt. ports cycle now
shows what is about to lose power and requires an explicit yes.
Declining reuses confirmation_required rather than adding an error kind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`power_cycle_port_sends_correct_command` only pinned down the client
method's endpoint/body, never the orchestration in
commands::ports::cycle that decides whether to call it at all. Add
mock-server tests for all four outcomes (confirmed, declined, non-PoE
conflict, missing port), using `.expect(0)` on the devmgr mock to prove
positively that decline/conflict/not-found never reach the controller.

Also add unit tests for cycle_summary, the prompt text an operator
reads before authorising a power cut: the connected-vs-stale
last_connection filter and the watt formatting were previously
untested.
…d doc

confirm_destructive wrote the y/N prompt without a trailing newline;
the user's Enter is echoed by the terminal, not this stream, so with a
TTY stdin and redirected stderr the confirmation_required envelope
printed on decline landed on the same physical line as the prompt,
breaking the "envelope is the last line of stderr" contract. Terminate
the prompt line explicitly after read_line.

Also widen the confirmation_required error kind's schema description:
it's now produced by both a missing --yes on a non-TTY and an
interactive decline, and an agent reading `unifi schema` couldn't
previously learn about the second producer. No kind/exit_code/retryable
change.

Document, at the port_poe guard rail in check_cyclable, that firmware
omitting the key also fails closed there deliberately (contrast the
Option<bool> treatment of autoneg/enable/is_uplink a few lines below).
- Restore devices ports's historical name -> model -> "Device" label
  fallback, lost when it was routed through the shared collect_rows
  (which keeps its "-" fallback for ports list / ports find). Threaded
  through a new collect_rows_with_fallback so the two call sites don't
  duplicate the whole flattening loop over one fallback string.
- Extract device_identity as the single place that derives a port
  row's device_mac/device_name, used by both show() and
  collect_rows_with_fallback() (previously duplicated verbatim).
- Replace devices.rs's four crate::commands::ports::* fully-qualified
  paths with a use crate::commands::ports::{self, PortRow}, matching
  the file's existing import style.
- Compute render_text's Device column width from the full result set
  via a new device_col_width helper, not the paginated page it's
  handed, so two --offset pages of the same ports list query render
  with the same column width.
- Give autoneg/enable/is_uplink each their own doc comment; the shared
  tri-state rationale previously sat only above autoneg.
- Add a test pinning ApiError::Conflict's no-added-prefix Display
  contract, and add .expect(1) to list_all_device_ports_returns_every_device's
  mock for consistency with its sibling tests.

Includes tests for all of the above, run via the real binary where the
existing suite's convention already spawns it (devices ports label
fallback, ports show text formatting, ports list column stability).
The docs claimed the pre-flight checks fire 'before contacting the
controller'. They do not: cycle() reads the port table via /stat/device
first, then applies the guard rails. Only the destructive power-cycle
command is withheld. A reader could otherwise assume a conflict/not_found
refusal implies no network activity, or that it works without valid
credentials -- neither is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
find's whole purpose is telling the operator which port a device is on
*now*, and this lookup feeds the destructive `ports cycle`. Previously
the text table gave no direct signal for that — only the connected-first
sort order distinguished the current port from stale history, which is
easy to misread. Append a `Connected` (yes/-) column, colored like the
existing Link column, without touching `list`/`devices ports` rendering:
render_text keeps its old signature and delegates to a shared render_rows
helper with connected=None, so those two callers are byte-identical to
before. JSON output (already carrying `connected`) is untouched.
A request came in for an --off-seconds flag on ports cycle. Verified
across five independent UniFi client libraries and Ubiquiti's own
Integration API spec that the power-cycle command accepts only
{cmd, mac, port_idx} on both the legacy and modern endpoints, with no
duration parameter — unlike restart on the same endpoint, which does
take an optional reboot_type, so the omission is deliberate rather than
an oversight. The interval is chosen by switch firmware and varies by
model/version; IEEE 802.3 detection timing imposes a roughly 1-2 second
floor regardless. Document the limitation instead of building a
pass-through that has nothing to pass through to.
A live UCG-Max controller rejected `power-cycle` on a port that passed
both existing guard rails (port_poe: true, poe_mode: "auto") with HTTP
400 api.err.InvalidTargetPort, which this CLI surfaced as `api_error`
(exit 5) — a kind the schema advertises as retryable, even though
retrying this request can never succeed. poe_enable: false was the
only attribute distinguishing that port from ones that do cycle; add
it as a third check_cyclable guard rail (after the poe_mode: "off"
check, which stays more specific and must win) so this now surfaces
locally as `conflict` (exit 6) before any HTTP write.

Two existing test fixtures (ports.rs's former "happy path" unit test
and mock_server.rs's confirmed/declined cycle tests) implicitly relied
on poe_enable defaulting to false and are corrected to be realistic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… count

Found by live testing against a real controller: `unifi ports find garage-pi`
returned a conflict because two client records share the name `garage-pi` —
the wired and wireless interfaces of one physical Raspberry Pi (MACs one
bit apart). Only the wired interface was ever attached to a switch port,
so the question `find` exists to answer had exactly one answer, but the
old code picked a single client via `resolve_identifier` before ever
consulting port tables and rejected the ambiguity outright. Six names on
the reporting controller collide this way, making it the common case for
any device with more than one interface, not a corner case.

`resolve_identifier` is replaced by `resolve_candidates`, which returns
every candidate MAC a name/IP could refer to without judging ambiguity.
`find` now fetches port tables once and computes matches for every
candidate, keeping only ones actually on a port: exactly one -> return its
rows, more than one -> conflict naming only the ported candidates (name,
MAC, and location), none -> not_found. A MAC-shaped identifier still
resolves locally with no client-list round trip.

No changes to exit codes, output fields, or JSON envelope shapes.
…generic placeholders

Regression fixtures added on this branch were written against real
hardware during live testing and captured real host names and MAC
addresses. Swap them for generic placeholders (aa:bb:cc:dd:ee:NN MACs,
neutral device names) before this branch is published, preserving every
fixture's exact shape: the wired/wireless one-bit-apart MAC pairs and the
name-substring ambiguity relationships still hold.
…e length

Coloured Link/Connected cells were built as owo_colors-wrapped strings and
handed to `{:<N}` specifiers, which count ANSI escape bytes and so emit no
padding once the escaped string already exceeds N bytes. Only visible at a
TTY; piped (uncoloured) output was always correct. The Link-column defect is
pre-existing upstream behaviour (predates this branch, affects `devices
ports` today); this fixes it there too, plus the Connected column added in
this branch for `ports find`. Also fixes the "1 ports" row-count trailer to
read "1 port" for a single row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rvben
rvben merged commit b14794e into rvben:main Aug 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants