unifi-cli 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/CHANGELOG.md +30 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/Cargo.lock +1 -1
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/Cargo.toml +1 -1
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/PKG-INFO +114 -10
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/README.md +113 -9
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/client.rs +21 -2
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/mod.rs +3 -3
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/tests.rs +79 -1
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/types.rs +42 -1
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/devices.rs +18 -93
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/mod.rs +1 -0
- unifi_cli-0.3.2/src/commands/ports.rs +1336 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/fields.rs +41 -1
- unifi_cli-0.3.2/src/lib.rs +25 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/main.rs +483 -23
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/output.rs +71 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/schema.rs +76 -8
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/cli_contract.rs +138 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/mock_server.rs +1474 -1
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/spec_compliance.rs +83 -0
- unifi_cli-0.3.0/src/lib.rs +0 -5
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/.github/workflows/release.yml +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/.gitignore +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/LICENSE +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/Makefile +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/prek.toml +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/pyproject.toml +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/clients.rs +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/events.rs +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/networks.rs +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/protect.rs +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/system.rs +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/tui.rs +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/fixtures/clispec-v0.2.json +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/unifi_cli/__init__.py +0 -0
- {unifi_cli-0.3.0 → unifi_cli-0.3.2}/unifi_cli/__main__.py +0 -0
|
@@ -2,15 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.2](https://github.com/rvben/unifi-cli/compare/v0.3.1...v0.3.2) - 2026-08-10
|
|
5
6
|
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **config**: write the config atomically on Windows too ([0c2977e](https://github.com/rvben/unifi-cli/commit/0c2977e238ccb09ca2949560568c4f8b8fd528db))
|
|
10
|
+
- **config**: replace the config file atomically instead of writing in place ([9ba19fb](https://github.com/rvben/unifi-cli/commit/9ba19fbde8b498488f43b1af1ab1d2771189d672))
|
|
11
|
+
- **output**: keep 408 and 429 retryable instead of permanent client errors ([1068bae](https://github.com/rvben/unifi-cli/commit/1068baed75ce232028fec0277c1ca38324723a35))
|
|
12
|
+
- **ports**: keep the attachment flag tri-state instead of reading absence as gone ([de2e770](https://github.com/rvben/unifi-cli/commit/de2e770d30cbc3cba64cf5b06640549a9655c776))
|
|
13
|
+
- **config**: create the credentials file with owner-only permissions ([8b7789d](https://github.com/rvben/unifi-cli/commit/8b7789d2d87edc5819e950573130000370c6939f))
|
|
14
|
+
- **schema**: publish an error and confirmation contract that matches runtime ([5176ccf](https://github.com/rvben/unifi-cli/commit/5176ccfefeb08df4fecd0d7a6d83882f5e06c57f))
|
|
15
|
+
- **ports**: report a stale last connection as unattached ([e80cda5](https://github.com/rvben/unifi-cli/commit/e80cda542fce27db385fc43bd69093c265ead464))
|
|
6
16
|
|
|
17
|
+
## [0.3.1](https://github.com/rvben/unifi-cli/compare/v0.3.0...v0.3.1) - 2026-08-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **ports**: add ports command tree with single-port PoE power-cycling ([b14794e](https://github.com/rvben/unifi-cli/commit/b14794e976638f7c4a5a21639ebe8a8b011ab25c))
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
7
24
|
|
|
25
|
+
- **cli**: prompt before every destructive command, not just ports cycle ([ae49798](https://github.com/rvben/unifi-cli/commit/ae4979886d63b4dd982a3a38de34a0ff343d5f18))
|
|
26
|
+
- **schema**: publish flags as boolean and port/watch as integer ([7c21144](https://github.com/rvben/unifi-cli/commit/7c21144d93afa68cb021a9191c769dea4af496ba))
|
|
27
|
+
- **ports**: give the PoE class label its colon and align the detail column ([1cd56d0](https://github.com/rvben/unifi-cli/commit/1cd56d03c3e0ec12a025030a051c135a1fa0865c))
|
|
8
28
|
|
|
9
29
|
## [0.3.0](https://github.com/rvben/unifi-cli/compare/v0.2.3...v0.3.0) - 2026-07-09
|
|
10
30
|
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **clients list**: project `ssid`, `signal`, `uptime`, `network`, `vlan`, `tx_bytes`, `rx_bytes`, `blocked` and `connected_at`. Answering "which SSID is each client on" cost one API call per client; it is now one call for the whole list.
|
|
34
|
+
- **networks list**: a `list` subcommand, matching `clients`, `devices` and `events`. Bare `unifi networks` still works.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **clients list**: `ip` is now the address a client currently holds, read from the live `stat/sta` record. It previously came from the integration API's `ipAddress`, which retains the last address a client ever had and outlives the lease. A client with no current lease now reports `ip: null` instead of a stale address, and `clients list` agrees with `clients show`.
|
|
39
|
+
|
|
11
40
|
### Fixed
|
|
12
41
|
|
|
13
42
|
- **clients**: stop three silent-failure modes in the agent contract ([5e50bb7](https://github.com/rvben/unifi-cli/commit/5e50bb704777407acf244856bc435f7ceccd7b00))
|
|
43
|
+
- **--fields**: an unknown field is now a usage error (exit 2) naming the offender and the valid set, raised before the config loads or a connection opens. It previously returned `{}` per row with exit 0, which is indistinguishable from a query that matched nothing. `schema` and `--fields` read the same field tables, so the published contract and the enforced one cannot drift.
|
|
14
44
|
|
|
15
45
|
## [0.2.3](https://github.com/rvben/unifi-cli/compare/v0.2.2...v0.2.3) - 2026-07-07
|
|
16
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -79,7 +79,17 @@ export UNIFI_ACCEPT_INVALID_CERTS=true
|
|
|
79
79
|
|
|
80
80
|
### Config file
|
|
81
81
|
|
|
82
|
-
`~/.config/unifi/config.toml
|
|
82
|
+
Linux: `~/.config/unifi/config.toml`.
|
|
83
|
+
macOS: `~/Library/Application Support/unifi/config.toml`.
|
|
84
|
+
Windows: `%APPDATA%\unifi\config.toml`.
|
|
85
|
+
|
|
86
|
+
`unifi config init` writes a new file and renames it over any existing config
|
|
87
|
+
instead of writing in place, so a failed write leaves the previous config
|
|
88
|
+
intact. On Linux and macOS the new file is created with mode 0600, since it
|
|
89
|
+
holds an API key and optionally a Protect password. Because the credentials go
|
|
90
|
+
straight into a file that is already 0600, they are never readable by another
|
|
91
|
+
local account, not even for the moment between the write and a chmod. On
|
|
92
|
+
Windows the file inherits its directory's ACL.
|
|
83
93
|
|
|
84
94
|
```toml
|
|
85
95
|
host = "https://unifi.example.com"
|
|
@@ -122,6 +132,20 @@ API keys, passwords, session cookies, and stream URLs.
|
|
|
122
132
|
When `unifi config init` cannot verify the controller's certificate, it offers
|
|
123
133
|
to trust the controller and saves `accept_invalid_certs = true` for you.
|
|
124
134
|
|
|
135
|
+
### Destructive commands
|
|
136
|
+
|
|
137
|
+
`clients block`, `clients unblock`, `clients kick`, `devices restart`,
|
|
138
|
+
`devices upgrade`, `ports cycle` and `protect rtsps delete` all ask before they
|
|
139
|
+
act. On a terminal you get a yes/no question naming the target; declining exits
|
|
140
|
+
2 with `kind: confirmation_required` and sends nothing. When stdin is not a
|
|
141
|
+
terminal there is nobody to ask, so they refuse with the same error unless you
|
|
142
|
+
pass `--yes`, which skips the question everywhere.
|
|
143
|
+
|
|
144
|
+
`unifi schema` marks exactly these commands `confirmation_required: true`, so a
|
|
145
|
+
caller can tell them apart from mutating commands that act immediately
|
|
146
|
+
(`devices locate`, `clients set-fixed-ip`, `protect rtsps create`) without
|
|
147
|
+
hardcoding the list.
|
|
148
|
+
|
|
125
149
|
## TUI dashboard
|
|
126
150
|
|
|
127
151
|
```bash
|
|
@@ -173,6 +197,79 @@ unifi devices locate aa:bb:cc:dd:ee:ff # Blink locate LED
|
|
|
173
197
|
unifi devices locate aa:bb:cc:dd:ee:ff --off # Stop blinking
|
|
174
198
|
```
|
|
175
199
|
|
|
200
|
+
### Ports
|
|
201
|
+
|
|
202
|
+
Find which switch port a device is plugged into, then power-cycle just that
|
|
203
|
+
port instead of rebooting the whole switch:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
# Which port is my Pi on? Matches by name (case-insensitive substring),
|
|
207
|
+
# MAC, or IP.
|
|
208
|
+
unifi ports find garage-pi
|
|
209
|
+
unifi ports find aa:bb:cc:dd:ee:10
|
|
210
|
+
|
|
211
|
+
# Inspect it: PoE mode, class, voltage, current, and what's attached
|
|
212
|
+
unifi ports show aa:bb:cc:dd:ee:ff 5
|
|
213
|
+
|
|
214
|
+
# Bounce PoE on that port only, leaving the rest of the switch untouched
|
|
215
|
+
unifi ports cycle aa:bb:cc:dd:ee:ff 5
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`ports find`'s output feeds directly into `show` and `cycle`: `device_mac`
|
|
219
|
+
and `port_idx` are the *switch's* MAC and port index, not the attached
|
|
220
|
+
device's. A name is ambiguous only when it matches more than one device
|
|
221
|
+
that's actually on a switch port; that returns `kind: conflict` (exit 6)
|
|
222
|
+
listing the candidates rather than guessing. Other client records sharing
|
|
223
|
+
the name (a device's WiFi interface reporting under the same name as its
|
|
224
|
+
wired one, say) don't cause a conflict if they're not themselves on a port.
|
|
225
|
+
A device that has moved between switch ports appears once per port it has
|
|
226
|
+
ever used, with a `connected` field distinguishing its current port from
|
|
227
|
+
stale history.
|
|
228
|
+
|
|
229
|
+
`ports show` exposes the port's PoE telemetry: `poe_mode`, `poe_class`,
|
|
230
|
+
`poe_voltage`, `poe_current`, `poe_good`, and the MAC of the attached device
|
|
231
|
+
(`attached_mac`). The controller keeps a port's last connection record after
|
|
232
|
+
the device is unplugged, so `attached_mac` is set only when the controller
|
|
233
|
+
affirms the record is live. The MAC is still reported as
|
|
234
|
+
`attached_last_seen_mac` and the controller's own flag as `attached_connected`
|
|
235
|
+
(true, false, or null when the firmware does not report it), so a caller can
|
|
236
|
+
tell "gone" from "not reported". The text output renders the three cases as
|
|
237
|
+
`aa:bb:cc:dd:ee:ff`, `- (last seen aa:bb:cc:dd:ee:ff)` and
|
|
238
|
+
`unknown (last seen aa:bb:cc:dd:ee:ff)`. Nothing that has been unplugged is
|
|
239
|
+
ever presented as currently attached.
|
|
240
|
+
|
|
241
|
+
`ports cycle` is destructive. On a terminal it shows what is about to lose
|
|
242
|
+
power and asks for confirmation; when piped it requires `--yes` and
|
|
243
|
+
otherwise exits 2 with `kind: confirmation_required`. It reads the port
|
|
244
|
+
table first, then refuses **without ever sending the power-cycle command**
|
|
245
|
+
when:
|
|
246
|
+
|
|
247
|
+
- the port is not PoE-capable (an SFP+ port, say) → `kind: conflict`, exit 6
|
|
248
|
+
- the port's PoE is administratively off → `kind: conflict`, exit 6
|
|
249
|
+
- the port isn't currently delivering PoE (`poe_enable: false`) → `kind: conflict`, exit 6
|
|
250
|
+
- the device has no such port index → `kind: not_found`, exit 4
|
|
251
|
+
|
|
252
|
+
The off interval (how long the port stays unpowered) is chosen by the
|
|
253
|
+
switch firmware, not by this CLI. The power-cycle command takes only the
|
|
254
|
+
target port, with no duration parameter, on either the legacy endpoint or
|
|
255
|
+
the Integration API, so the interval isn't configurable and varies by
|
|
256
|
+
device model and firmware version. IEEE 802.3 PoE detection timing imposes
|
|
257
|
+
a floor regardless: expect the port to sit dark for roughly 1-2 seconds at
|
|
258
|
+
minimum before power returns.
|
|
259
|
+
|
|
260
|
+
List ports for one device, or across every device:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
unifi ports list aa:bb:cc:dd:ee:ff
|
|
264
|
+
unifi ports list --limit 20 --fields port_idx,poe_power
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`ports list` returns the paginated `{items, total, limit, offset}` envelope
|
|
268
|
+
used by the other list commands. `unifi devices ports <MAC>` remains an
|
|
269
|
+
alias for `unifi ports list <MAC>`; it keeps its original bare-JSON-array
|
|
270
|
+
shape for backward compatibility, and both emit the same per-row fields,
|
|
271
|
+
including `device_mac` and `device_name`.
|
|
272
|
+
|
|
176
273
|
### Events
|
|
177
274
|
|
|
178
275
|
```bash
|
|
@@ -252,14 +349,21 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
|
|
|
252
349
|
|
|
253
350
|
### Distinct exit codes
|
|
254
351
|
|
|
255
|
-
| Code | Meaning |
|
|
256
|
-
|
|
257
|
-
| 0 | Success |
|
|
258
|
-
| 1 | General error |
|
|
259
|
-
| 2 | Configuration error |
|
|
260
|
-
|
|
|
261
|
-
|
|
|
262
|
-
|
|
|
352
|
+
| Code | `kind` | Meaning |
|
|
353
|
+
|------|--------|---------|
|
|
354
|
+
| 0 | - | Success |
|
|
355
|
+
| 1 | `general_error` | General error (including transport failures) |
|
|
356
|
+
| 2 | `config_error` | Configuration or usage error |
|
|
357
|
+
| 2 | `confirmation_required` | A destructive command ran without `--yes` and without a TTY |
|
|
358
|
+
| 3 | `auth_error` | Authentication error (401/403) |
|
|
359
|
+
| 4 | `not_found` | Not found (404) |
|
|
360
|
+
| 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
|
|
361
|
+
| 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
|
|
362
|
+
| 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
|
|
363
|
+
| 6 | `conflict` | The request cannot succeed against the resource's current state, refused locally before any API call |
|
|
364
|
+
|
|
365
|
+
`unifi schema` publishes the same table under `errors`, with a `retryable` flag
|
|
366
|
+
per kind, so an agent can branch on it without parsing prose.
|
|
263
367
|
|
|
264
368
|
## Development
|
|
265
369
|
|
|
@@ -58,7 +58,17 @@ export UNIFI_ACCEPT_INVALID_CERTS=true
|
|
|
58
58
|
|
|
59
59
|
### Config file
|
|
60
60
|
|
|
61
|
-
`~/.config/unifi/config.toml
|
|
61
|
+
Linux: `~/.config/unifi/config.toml`.
|
|
62
|
+
macOS: `~/Library/Application Support/unifi/config.toml`.
|
|
63
|
+
Windows: `%APPDATA%\unifi\config.toml`.
|
|
64
|
+
|
|
65
|
+
`unifi config init` writes a new file and renames it over any existing config
|
|
66
|
+
instead of writing in place, so a failed write leaves the previous config
|
|
67
|
+
intact. On Linux and macOS the new file is created with mode 0600, since it
|
|
68
|
+
holds an API key and optionally a Protect password. Because the credentials go
|
|
69
|
+
straight into a file that is already 0600, they are never readable by another
|
|
70
|
+
local account, not even for the moment between the write and a chmod. On
|
|
71
|
+
Windows the file inherits its directory's ACL.
|
|
62
72
|
|
|
63
73
|
```toml
|
|
64
74
|
host = "https://unifi.example.com"
|
|
@@ -101,6 +111,20 @@ API keys, passwords, session cookies, and stream URLs.
|
|
|
101
111
|
When `unifi config init` cannot verify the controller's certificate, it offers
|
|
102
112
|
to trust the controller and saves `accept_invalid_certs = true` for you.
|
|
103
113
|
|
|
114
|
+
### Destructive commands
|
|
115
|
+
|
|
116
|
+
`clients block`, `clients unblock`, `clients kick`, `devices restart`,
|
|
117
|
+
`devices upgrade`, `ports cycle` and `protect rtsps delete` all ask before they
|
|
118
|
+
act. On a terminal you get a yes/no question naming the target; declining exits
|
|
119
|
+
2 with `kind: confirmation_required` and sends nothing. When stdin is not a
|
|
120
|
+
terminal there is nobody to ask, so they refuse with the same error unless you
|
|
121
|
+
pass `--yes`, which skips the question everywhere.
|
|
122
|
+
|
|
123
|
+
`unifi schema` marks exactly these commands `confirmation_required: true`, so a
|
|
124
|
+
caller can tell them apart from mutating commands that act immediately
|
|
125
|
+
(`devices locate`, `clients set-fixed-ip`, `protect rtsps create`) without
|
|
126
|
+
hardcoding the list.
|
|
127
|
+
|
|
104
128
|
## TUI dashboard
|
|
105
129
|
|
|
106
130
|
```bash
|
|
@@ -152,6 +176,79 @@ unifi devices locate aa:bb:cc:dd:ee:ff # Blink locate LED
|
|
|
152
176
|
unifi devices locate aa:bb:cc:dd:ee:ff --off # Stop blinking
|
|
153
177
|
```
|
|
154
178
|
|
|
179
|
+
### Ports
|
|
180
|
+
|
|
181
|
+
Find which switch port a device is plugged into, then power-cycle just that
|
|
182
|
+
port instead of rebooting the whole switch:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# Which port is my Pi on? Matches by name (case-insensitive substring),
|
|
186
|
+
# MAC, or IP.
|
|
187
|
+
unifi ports find garage-pi
|
|
188
|
+
unifi ports find aa:bb:cc:dd:ee:10
|
|
189
|
+
|
|
190
|
+
# Inspect it: PoE mode, class, voltage, current, and what's attached
|
|
191
|
+
unifi ports show aa:bb:cc:dd:ee:ff 5
|
|
192
|
+
|
|
193
|
+
# Bounce PoE on that port only, leaving the rest of the switch untouched
|
|
194
|
+
unifi ports cycle aa:bb:cc:dd:ee:ff 5
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`ports find`'s output feeds directly into `show` and `cycle`: `device_mac`
|
|
198
|
+
and `port_idx` are the *switch's* MAC and port index, not the attached
|
|
199
|
+
device's. A name is ambiguous only when it matches more than one device
|
|
200
|
+
that's actually on a switch port; that returns `kind: conflict` (exit 6)
|
|
201
|
+
listing the candidates rather than guessing. Other client records sharing
|
|
202
|
+
the name (a device's WiFi interface reporting under the same name as its
|
|
203
|
+
wired one, say) don't cause a conflict if they're not themselves on a port.
|
|
204
|
+
A device that has moved between switch ports appears once per port it has
|
|
205
|
+
ever used, with a `connected` field distinguishing its current port from
|
|
206
|
+
stale history.
|
|
207
|
+
|
|
208
|
+
`ports show` exposes the port's PoE telemetry: `poe_mode`, `poe_class`,
|
|
209
|
+
`poe_voltage`, `poe_current`, `poe_good`, and the MAC of the attached device
|
|
210
|
+
(`attached_mac`). The controller keeps a port's last connection record after
|
|
211
|
+
the device is unplugged, so `attached_mac` is set only when the controller
|
|
212
|
+
affirms the record is live. The MAC is still reported as
|
|
213
|
+
`attached_last_seen_mac` and the controller's own flag as `attached_connected`
|
|
214
|
+
(true, false, or null when the firmware does not report it), so a caller can
|
|
215
|
+
tell "gone" from "not reported". The text output renders the three cases as
|
|
216
|
+
`aa:bb:cc:dd:ee:ff`, `- (last seen aa:bb:cc:dd:ee:ff)` and
|
|
217
|
+
`unknown (last seen aa:bb:cc:dd:ee:ff)`. Nothing that has been unplugged is
|
|
218
|
+
ever presented as currently attached.
|
|
219
|
+
|
|
220
|
+
`ports cycle` is destructive. On a terminal it shows what is about to lose
|
|
221
|
+
power and asks for confirmation; when piped it requires `--yes` and
|
|
222
|
+
otherwise exits 2 with `kind: confirmation_required`. It reads the port
|
|
223
|
+
table first, then refuses **without ever sending the power-cycle command**
|
|
224
|
+
when:
|
|
225
|
+
|
|
226
|
+
- the port is not PoE-capable (an SFP+ port, say) → `kind: conflict`, exit 6
|
|
227
|
+
- the port's PoE is administratively off → `kind: conflict`, exit 6
|
|
228
|
+
- the port isn't currently delivering PoE (`poe_enable: false`) → `kind: conflict`, exit 6
|
|
229
|
+
- the device has no such port index → `kind: not_found`, exit 4
|
|
230
|
+
|
|
231
|
+
The off interval (how long the port stays unpowered) is chosen by the
|
|
232
|
+
switch firmware, not by this CLI. The power-cycle command takes only the
|
|
233
|
+
target port, with no duration parameter, on either the legacy endpoint or
|
|
234
|
+
the Integration API, so the interval isn't configurable and varies by
|
|
235
|
+
device model and firmware version. IEEE 802.3 PoE detection timing imposes
|
|
236
|
+
a floor regardless: expect the port to sit dark for roughly 1-2 seconds at
|
|
237
|
+
minimum before power returns.
|
|
238
|
+
|
|
239
|
+
List ports for one device, or across every device:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
unifi ports list aa:bb:cc:dd:ee:ff
|
|
243
|
+
unifi ports list --limit 20 --fields port_idx,poe_power
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`ports list` returns the paginated `{items, total, limit, offset}` envelope
|
|
247
|
+
used by the other list commands. `unifi devices ports <MAC>` remains an
|
|
248
|
+
alias for `unifi ports list <MAC>`; it keeps its original bare-JSON-array
|
|
249
|
+
shape for backward compatibility, and both emit the same per-row fields,
|
|
250
|
+
including `device_mac` and `device_name`.
|
|
251
|
+
|
|
155
252
|
### Events
|
|
156
253
|
|
|
157
254
|
```bash
|
|
@@ -231,14 +328,21 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
|
|
|
231
328
|
|
|
232
329
|
### Distinct exit codes
|
|
233
330
|
|
|
234
|
-
| Code | Meaning |
|
|
235
|
-
|
|
236
|
-
| 0 | Success |
|
|
237
|
-
| 1 | General error |
|
|
238
|
-
| 2 | Configuration error |
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
|
|
|
331
|
+
| Code | `kind` | Meaning |
|
|
332
|
+
|------|--------|---------|
|
|
333
|
+
| 0 | - | Success |
|
|
334
|
+
| 1 | `general_error` | General error (including transport failures) |
|
|
335
|
+
| 2 | `config_error` | Configuration or usage error |
|
|
336
|
+
| 2 | `confirmation_required` | A destructive command ran without `--yes` and without a TTY |
|
|
337
|
+
| 3 | `auth_error` | Authentication error (401/403) |
|
|
338
|
+
| 4 | `not_found` | Not found (404) |
|
|
339
|
+
| 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
|
|
340
|
+
| 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
|
|
341
|
+
| 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
|
|
342
|
+
| 6 | `conflict` | The request cannot succeed against the resource's current state, refused locally before any API call |
|
|
343
|
+
|
|
344
|
+
`unifi schema` publishes the same table under `errors`, with a `retryable` flag
|
|
345
|
+
per kind, so an agent can branch on it without parsing prose.
|
|
242
346
|
|
|
243
347
|
## Development
|
|
244
348
|
|
|
@@ -105,7 +105,7 @@ impl UnifiClient {
|
|
|
105
105
|
.get_integration("/proxy/network/integration/v1/sites")
|
|
106
106
|
.await?;
|
|
107
107
|
let site = resp.data.into_iter().next().ok_or_else(|| {
|
|
108
|
-
ApiError::Other("No sites found
|
|
108
|
+
ApiError::Other("No sites found. Check that the API key has site access".into())
|
|
109
109
|
})?;
|
|
110
110
|
self.site_id = Some(site.id);
|
|
111
111
|
}
|
|
@@ -339,6 +339,18 @@ impl UnifiClient {
|
|
|
339
339
|
Ok(())
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/// Power-cycle a single PoE port. `mac` is the **switch's** MAC, not the
|
|
343
|
+
/// attached device's.
|
|
344
|
+
pub async fn power_cycle_port(&self, mac: &str, port_idx: u32) -> Result<(), ApiError> {
|
|
345
|
+
let formatted = format_mac(&normalize_mac(mac));
|
|
346
|
+
self.post_legacy_cmd(
|
|
347
|
+
"devmgr",
|
|
348
|
+
serde_json::json!({"cmd": "power-cycle", "mac": formatted, "port_idx": port_idx}),
|
|
349
|
+
)
|
|
350
|
+
.await?;
|
|
351
|
+
Ok(())
|
|
352
|
+
}
|
|
353
|
+
|
|
342
354
|
pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
|
|
343
355
|
let formatted = format_mac(&normalize_mac(mac));
|
|
344
356
|
self.post_legacy_cmd(
|
|
@@ -407,6 +419,13 @@ impl UnifiClient {
|
|
|
407
419
|
.ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
|
|
408
420
|
}
|
|
409
421
|
|
|
422
|
+
/// Every device that reports a port table, in one request. `/stat/device`
|
|
423
|
+
/// already returns all devices with their port tables, so the unfiltered
|
|
424
|
+
/// listing costs no more than the filtered one.
|
|
425
|
+
pub async fn list_all_device_ports(&self) -> Result<Vec<DeviceWithPorts>, ApiError> {
|
|
426
|
+
self.get_legacy("/stat/device").await
|
|
427
|
+
}
|
|
428
|
+
|
|
410
429
|
// All clients with bandwidth data (legacy endpoint for richer stats)
|
|
411
430
|
pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
|
|
412
431
|
self.get_legacy("/stat/sta").await
|
|
@@ -558,7 +577,7 @@ impl ProtectSession {
|
|
|
558
577
|
) -> Result<Self, ApiError> {
|
|
559
578
|
let base_url = normalize_base_url(host)?;
|
|
560
579
|
|
|
561
|
-
// Don't use cookie_provider
|
|
580
|
+
// Don't use cookie_provider: the `partitioned` cookie attribute
|
|
562
581
|
// isn't handled by reqwest's jar. We extract the token manually.
|
|
563
582
|
let http = reqwest::Client::builder()
|
|
564
583
|
.danger_accept_invalid_certs(options.accept_invalid_certs)
|
|
@@ -6,7 +6,7 @@ pub use client::ProtectSession;
|
|
|
6
6
|
pub use client::UnifiClient;
|
|
7
7
|
pub use client::error_for_status;
|
|
8
8
|
pub use types::{
|
|
9
|
-
ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem,
|
|
10
|
-
LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes,
|
|
11
|
-
format_uptime, normalize_mac,
|
|
9
|
+
ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LastConnection,
|
|
10
|
+
LegacyClient, LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes,
|
|
11
|
+
format_mac, format_uptime, normalize_mac,
|
|
12
12
|
};
|
|
@@ -145,6 +145,17 @@ fn api_error_display_auth() {
|
|
|
145
145
|
assert!(display.contains("Hint:"));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
#[test]
|
|
149
|
+
fn api_error_display_conflict_has_no_prefix() {
|
|
150
|
+
// Unlike Auth/NotFound (sentence fragments that get a prefix added),
|
|
151
|
+
// Conflict messages are written whole, to be read by an operator as-is.
|
|
152
|
+
let err = ApiError::Conflict("Port 5 on aa:bb:cc:dd:ee:ff does not support PoE.".into());
|
|
153
|
+
assert_eq!(
|
|
154
|
+
err.to_string(),
|
|
155
|
+
"Port 5 on aa:bb:cc:dd:ee:ff does not support PoE."
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
148
159
|
#[test]
|
|
149
160
|
fn api_error_display_other() {
|
|
150
161
|
let err = ApiError::Other("something went wrong".into());
|
|
@@ -762,7 +773,7 @@ fn client_clean_name_falls_back_to_hostname() {
|
|
|
762
773
|
|
|
763
774
|
#[test]
|
|
764
775
|
fn client_clean_name_hyphenated_not_stripped() {
|
|
765
|
-
// "host-ee:ff" has no space before the hex
|
|
776
|
+
// "host-ee:ff" has no space before the hex, so it's part of the hostname, not a MAC suffix
|
|
766
777
|
let json = r#"{"hostname": "host-ee:ff", "macAddress": "aa:bb:cc:dd:ee:ff"}"#;
|
|
767
778
|
let client: Client = serde_json::from_str(json).unwrap();
|
|
768
779
|
assert_eq!(client.clean_name(), "host-ee:ff");
|
|
@@ -812,3 +823,70 @@ fn legacy_client_clean_name_no_mac() {
|
|
|
812
823
|
let client: LegacyClient = serde_json::from_str(json).unwrap();
|
|
813
824
|
assert_eq!(client.clean_name(), "device ee:ff");
|
|
814
825
|
}
|
|
826
|
+
|
|
827
|
+
// --- PortEntry PoE telemetry ---
|
|
828
|
+
|
|
829
|
+
#[test]
|
|
830
|
+
fn port_entry_parses_poe_telemetry_with_string_numbers() {
|
|
831
|
+
// The controller returns poe_voltage/poe_current/poe_power as JSON
|
|
832
|
+
// strings on some firmware, the same quirk PR #3 fixed for poe_power.
|
|
833
|
+
let json = serde_json::json!({
|
|
834
|
+
"port_idx": 3,
|
|
835
|
+
"name": "Port 3",
|
|
836
|
+
"up": true,
|
|
837
|
+
"port_poe": true,
|
|
838
|
+
"poe_enable": true,
|
|
839
|
+
"poe_mode": "auto",
|
|
840
|
+
"poe_class": "Class 3",
|
|
841
|
+
"poe_power": "5.00",
|
|
842
|
+
"poe_voltage": "53.75",
|
|
843
|
+
"poe_current": "93.00",
|
|
844
|
+
"poe_good": true,
|
|
845
|
+
"autoneg": true,
|
|
846
|
+
"enable": true,
|
|
847
|
+
"is_uplink": false,
|
|
848
|
+
"stp_state": "forwarding",
|
|
849
|
+
"tx_errors": 0,
|
|
850
|
+
"rx_errors": 0,
|
|
851
|
+
"last_connection": {
|
|
852
|
+
"mac": "aa:bb:cc:dd:ee:20",
|
|
853
|
+
"connected": true,
|
|
854
|
+
"last_seen": 1783622695
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
let p: PortEntry = serde_json::from_value(json).expect("PortEntry must parse");
|
|
858
|
+
assert_eq!(p.poe_mode.as_deref(), Some("auto"));
|
|
859
|
+
assert_eq!(p.poe_class.as_deref(), Some("Class 3"));
|
|
860
|
+
assert_eq!(p.poe_voltage, Some(53.75));
|
|
861
|
+
assert_eq!(p.poe_current, Some(93.00));
|
|
862
|
+
assert_eq!(p.poe_good, Some(true));
|
|
863
|
+
assert_eq!(p.stp_state.as_deref(), Some("forwarding"));
|
|
864
|
+
assert_eq!(p.autoneg, Some(true));
|
|
865
|
+
assert_eq!(p.enable, Some(true));
|
|
866
|
+
assert_eq!(p.is_uplink, Some(false));
|
|
867
|
+
let lc = p.last_connection.expect("last_connection present");
|
|
868
|
+
assert_eq!(lc.mac.as_deref(), Some("aa:bb:cc:dd:ee:20"));
|
|
869
|
+
assert_eq!(lc.connected, Some(true));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
#[test]
|
|
873
|
+
fn port_entry_tolerates_absent_last_connection() {
|
|
874
|
+
// A port nothing has ever linked to omits last_connection entirely.
|
|
875
|
+
// This is exactly how the empty test-target port was identified.
|
|
876
|
+
let json = serde_json::json!({
|
|
877
|
+
"port_idx": 4,
|
|
878
|
+
"name": "Port 4",
|
|
879
|
+
"up": false,
|
|
880
|
+
"port_poe": true,
|
|
881
|
+
"poe_enable": false,
|
|
882
|
+
"poe_mode": "auto"
|
|
883
|
+
});
|
|
884
|
+
let p: PortEntry = serde_json::from_value(json).expect("PortEntry must parse");
|
|
885
|
+
assert!(p.last_connection.is_none());
|
|
886
|
+
assert_eq!(p.poe_voltage, None);
|
|
887
|
+
assert!(!p.up);
|
|
888
|
+
// Absent tri-state keys must read as unknown, never as a confident "no".
|
|
889
|
+
assert_eq!(p.enable, None, "absent enable must not read as disabled");
|
|
890
|
+
assert_eq!(p.autoneg, None);
|
|
891
|
+
assert_eq!(p.is_uplink, None);
|
|
892
|
+
}
|
|
@@ -302,10 +302,43 @@ pub struct PortEntry {
|
|
|
302
302
|
pub poe_power: Option<f64>,
|
|
303
303
|
#[serde(default)]
|
|
304
304
|
pub port_poe: bool,
|
|
305
|
+
/// "auto", "off", "passthrough", "passive24v". Absent on some firmware.
|
|
306
|
+
pub poe_mode: Option<String>,
|
|
307
|
+
pub poe_class: Option<String>,
|
|
308
|
+
#[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
|
|
309
|
+
pub poe_voltage: Option<f64>,
|
|
310
|
+
#[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
|
|
311
|
+
pub poe_current: Option<f64>,
|
|
312
|
+
pub poe_good: Option<bool>,
|
|
313
|
+
/// Auto-negotiation state. `Option`, not a defaulted bool, like `enable`
|
|
314
|
+
/// and `is_uplink` below: a firmware that omits this key must not be
|
|
315
|
+
/// reported as "auto-negotiation off". Matches `poe_good` above; contrast
|
|
316
|
+
/// `up`/`poe_enable`, where an absent key genuinely does mean false.
|
|
317
|
+
pub autoneg: Option<bool>,
|
|
318
|
+
/// Administrative enable state. Same tri-state rationale as `autoneg`: an
|
|
319
|
+
/// absent key must not be reported as "port administratively disabled".
|
|
320
|
+
pub enable: Option<bool>,
|
|
321
|
+
/// Whether this port is the switch's uplink. Same tri-state rationale as
|
|
322
|
+
/// `autoneg`: an absent key must not be reported as "not an uplink".
|
|
323
|
+
pub is_uplink: Option<bool>,
|
|
324
|
+
pub stp_state: Option<String>,
|
|
325
|
+
pub tx_errors: Option<u64>,
|
|
326
|
+
pub rx_errors: Option<u64>,
|
|
327
|
+
/// Absent entirely on a port nothing has linked to within retention.
|
|
328
|
+
pub last_connection: Option<LastConnection>,
|
|
305
329
|
pub tx_bytes: Option<u64>,
|
|
306
330
|
pub rx_bytes: Option<u64>,
|
|
307
331
|
}
|
|
308
332
|
|
|
333
|
+
/// The device most recently seen on a port. `connected` distinguishes a live
|
|
334
|
+
/// attachment from a stale record of a device that has since moved.
|
|
335
|
+
#[derive(Debug, Deserialize)]
|
|
336
|
+
pub struct LastConnection {
|
|
337
|
+
pub mac: Option<String>,
|
|
338
|
+
pub connected: Option<bool>,
|
|
339
|
+
pub last_seen: Option<u64>,
|
|
340
|
+
}
|
|
341
|
+
|
|
309
342
|
fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
|
|
310
343
|
where
|
|
311
344
|
D: serde::Deserializer<'de>,
|
|
@@ -485,9 +518,16 @@ pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
|
|
|
485
518
|
#[derive(Debug)]
|
|
486
519
|
pub enum ApiError {
|
|
487
520
|
Http(reqwest::Error),
|
|
488
|
-
Api {
|
|
521
|
+
Api {
|
|
522
|
+
status: u16,
|
|
523
|
+
message: String,
|
|
524
|
+
},
|
|
489
525
|
NotFound(String),
|
|
490
526
|
Auth(String),
|
|
527
|
+
/// A request that cannot succeed against the resource's current state,
|
|
528
|
+
/// rejected locally before any HTTP call. Published by `unifi schema`
|
|
529
|
+
/// as kind `conflict`, exit code 6.
|
|
530
|
+
Conflict(String),
|
|
491
531
|
Other(String),
|
|
492
532
|
}
|
|
493
533
|
|
|
@@ -570,6 +610,7 @@ impl fmt::Display for ApiError {
|
|
|
570
610
|
"\n Hint: Check your API key. Generate one in UniFi Settings > API"
|
|
571
611
|
)
|
|
572
612
|
}
|
|
613
|
+
ApiError::Conflict(msg) => write!(f, "{msg}"),
|
|
573
614
|
ApiError::Other(msg) => write!(f, "{msg}"),
|
|
574
615
|
}
|
|
575
616
|
}
|