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.
Files changed (37) hide show
  1. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/CHANGELOG.md +30 -0
  2. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/Cargo.lock +1 -1
  3. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/Cargo.toml +1 -1
  4. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/PKG-INFO +114 -10
  5. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/README.md +113 -9
  6. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/client.rs +21 -2
  7. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/mod.rs +3 -3
  8. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/tests.rs +79 -1
  9. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/api/types.rs +42 -1
  10. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/devices.rs +18 -93
  11. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/mod.rs +1 -0
  12. unifi_cli-0.3.2/src/commands/ports.rs +1336 -0
  13. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/fields.rs +41 -1
  14. unifi_cli-0.3.2/src/lib.rs +25 -0
  15. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/main.rs +483 -23
  16. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/output.rs +71 -0
  17. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/schema.rs +76 -8
  18. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/cli_contract.rs +138 -0
  19. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/mock_server.rs +1474 -1
  20. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/spec_compliance.rs +83 -0
  21. unifi_cli-0.3.0/src/lib.rs +0 -5
  22. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/.github/workflows/ci.yml +0 -0
  23. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/.github/workflows/release.yml +0 -0
  24. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/.gitignore +0 -0
  25. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/LICENSE +0 -0
  26. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/Makefile +0 -0
  27. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/prek.toml +0 -0
  28. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/pyproject.toml +0 -0
  29. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/clients.rs +0 -0
  30. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/events.rs +0 -0
  31. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/networks.rs +0 -0
  32. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/protect.rs +0 -0
  33. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/commands/system.rs +0 -0
  34. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/src/tui.rs +0 -0
  35. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/tests/fixtures/clispec-v0.2.json +0 -0
  36. {unifi_cli-0.3.0 → unifi_cli-0.3.2}/unifi_cli/__init__.py +0 -0
  37. {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
 
@@ -2786,7 +2786,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2786
2786
 
2787
2787
  [[package]]
2788
2788
  name = "unifi-cli"
2789
- version = "0.3.0"
2789
+ version = "0.3.2"
2790
2790
  dependencies = [
2791
2791
  "clap",
2792
2792
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "unifi-cli"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for UniFi Network controller"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-cli
3
- Version: 0.3.0
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
- | 3 | Authentication error (401/403) |
261
- | 4 | Not found (404) |
262
- | 5 | API error (server error) |
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
- | 3 | Authentication error (401/403) |
240
- | 4 | Not found (404) |
241
- | 5 | API error (server error) |
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 — check that the API key has site access".into())
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 — the `partitioned` cookie attribute
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, LegacyClient,
10
- LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes, format_mac,
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 — it's part of the hostname, not a MAC suffix
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 { status: u16, message: String },
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
  }