unifi-cli 0.3.2__tar.gz → 0.3.4__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.2 → unifi_cli-0.3.4}/CHANGELOG.md +33 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/Cargo.lock +2 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/Cargo.toml +4 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/PKG-INFO +2 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/README.md +1 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/client.rs +129 -36
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/mod.rs +2 -2
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/tests.rs +36 -32
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/types.rs +78 -18
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/clients.rs +72 -43
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/devices.rs +1 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/ports.rs +6 -6
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/protect.rs +101 -63
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/system.rs +20 -6
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/main.rs +30 -7
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/output.rs +97 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/schema.rs +22 -3
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/tui.rs +1323 -266
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/cli_contract.rs +47 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/mock_server.rs +1635 -103
- unifi_cli-0.3.4/tests/no_real_network_data.rs +245 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/.github/workflows/release.yml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/.gitignore +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/LICENSE +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/Makefile +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/prek.toml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/pyproject.toml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/events.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/mod.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/networks.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/fields.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/lib.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/fixtures/clispec-v0.2.json +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/spec_compliance.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/unifi_cli/__init__.py +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.4}/unifi_cli/__main__.py +0 -0
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.4](https://github.com/rvben/unifi-cli/compare/v0.3.3...v0.3.4) - 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- **protect**: wrap the camera list in the standard items envelope ([e7b77bf](https://github.com/rvben/unifi-cli/commit/e7b77bf06dc64def4de1312bd3a5d6a207e60262))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **protect**: wrap the camera list in the standard items envelope ([e7b77bf](https://github.com/rvben/unifi-cli/commit/e7b77bf06dc64def4de1312bd3a5d6a207e60262))
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **devices**: emit the firmware field devices show declares ([0f007c1](https://github.com/rvben/unifi-cli/commit/0f007c186a285b7250d5a539cebc1b561ba2be80))
|
|
18
|
+
- **system**: report an unchecked update state as unknown, not up to date ([38492bb](https://github.com/rvben/unifi-cli/commit/38492bb6ff45f62e234bcc7409180b683f9a64c0))
|
|
19
|
+
- **protect**: make the remaining reported-flag defaults tri-state ([4decf08](https://github.com/rvben/unifi-cli/commit/4decf08b38a7ed1ef31cfa792d492f3bfc5df76e))
|
|
20
|
+
- **clients**: rank and print unknown traffic as unknown, not zero ([cbe0b66](https://github.com/rvben/unifi-cli/commit/cbe0b663fd9529c918c40a91f27e1b504e674984))
|
|
21
|
+
- **schema**: publish the fields that say an rtsps create was partial ([9eb083e](https://github.com/rvben/unifi-cli/commit/9eb083e2031e192fceec6bbb1aa09c2fac5a3055))
|
|
22
|
+
- **protect**: stop turning absent camera data into confident values ([29cf091](https://github.com/rvben/unifi-cli/commit/29cf091955b5452814957961b066ff4ddc41a8e0))
|
|
23
|
+
- **tui**: let a throughput measurement expire instead of standing forever ([d7ca1f8](https://github.com/rvben/unifi-cli/commit/d7ca1f8499461990d6390427cdffa071716ecec8))
|
|
24
|
+
- **tui**: stop reporting throughput for a port whose link has gone down ([00a18d8](https://github.com/rvben/unifi-cli/commit/00a18d811e969697b78e7260e5515715c336df3e))
|
|
25
|
+
- **tui**: measure port throughput over the window the counters actually changed ([b2ae602](https://github.com/rvben/unifi-cli/commit/b2ae60228f1d8be44a2ae158a4b8a840d371d8fd))
|
|
26
|
+
- **cli**: exit quietly when the output pipe closes early ([518fd96](https://github.com/rvben/unifi-cli/commit/518fd963f9c6eaf21b994ec6439ffa16cb6097a9))
|
|
27
|
+
- **tui**: aim overlay actions at the entity the panel was opened on ([43fdd32](https://github.com/rvben/unifi-cli/commit/43fdd32abfa71fe1a1e7810e87af9c1033deb224))
|
|
28
|
+
- **events**: report an absent event surface as unsupported, not a rejected request ([b3be651](https://github.com/rvben/unifi-cli/commit/b3be65186c65dd0d5849c47f388da63d5eb3a347))
|
|
29
|
+
|
|
30
|
+
## [0.3.3](https://github.com/rvben/unifi-cli/compare/v0.3.2...v0.3.3) - 2026-08-10
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **api**: decode a response body before judging its content type ([0e49404](https://github.com/rvben/unifi-cli/commit/0e4940467e428f4cea20f54c1eafbc337b6cca00))
|
|
35
|
+
- **api**: report an absent controller application as unsupported ([0f5384c](https://github.com/rvben/unifi-cli/commit/0f5384c84caccc3dfcbf5b5b63831cd97eca55b5))
|
|
36
|
+
|
|
5
37
|
## [0.3.2](https://github.com/rvben/unifi-cli/compare/v0.3.1...v0.3.2) - 2026-08-10
|
|
6
38
|
|
|
7
39
|
### Fixed
|
|
@@ -130,7 +162,7 @@ Thanks to [@l3wi](https://github.com/l3wi) for reporting the TLS certificate val
|
|
|
130
162
|
- TUI: device panel shows all devices without scrolling, cursor moves through items
|
|
131
163
|
- TUI: client list viewport follows cursor instead of cursor staying at top
|
|
132
164
|
- CLI: dynamic column widths adapt to data instead of fixed widths causing misalignment
|
|
133
|
-
- CLI: MAC suffixes stripped from display names (e.g., "
|
|
165
|
+
- CLI: MAC suffixes stripped from display names (e.g., "lobby-bluetooth-proxy" instead of "lobby-bluetooth-proxy 43:3c")
|
|
134
166
|
|
|
135
167
|
### Changed
|
|
136
168
|
- TUI: overlay shortcut hints moved from footer to overlay bottom border
|
|
@@ -2786,13 +2786,14 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|
|
2786
2786
|
|
|
2787
2787
|
[[package]]
|
|
2788
2788
|
name = "unifi-cli"
|
|
2789
|
-
version = "0.3.
|
|
2789
|
+
version = "0.3.4"
|
|
2790
2790
|
dependencies = [
|
|
2791
2791
|
"clap",
|
|
2792
2792
|
"clap_complete",
|
|
2793
2793
|
"crossterm",
|
|
2794
2794
|
"dirs",
|
|
2795
2795
|
"jsonschema",
|
|
2796
|
+
"libc",
|
|
2796
2797
|
"owo-colors",
|
|
2797
2798
|
"ratatui",
|
|
2798
2799
|
"reqwest",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "unifi-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.90"
|
|
6
6
|
description = "CLI for UniFi Network controller"
|
|
@@ -33,6 +33,9 @@ ratatui = "0.30"
|
|
|
33
33
|
crossterm = "0.29"
|
|
34
34
|
rpassword = "7.4.0"
|
|
35
35
|
|
|
36
|
+
[target.'cfg(unix)'.dependencies]
|
|
37
|
+
libc = "0.2"
|
|
38
|
+
|
|
36
39
|
[dev-dependencies]
|
|
37
40
|
tempfile = "3"
|
|
38
41
|
wiremock = "0.6"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -357,6 +357,7 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
|
|
|
357
357
|
| 2 | `confirmation_required` | A destructive command ran without `--yes` and without a TTY |
|
|
358
358
|
| 3 | `auth_error` | Authentication error (401/403) |
|
|
359
359
|
| 4 | `not_found` | Not found (404) |
|
|
360
|
+
| 4 | `unsupported` | The controller does not serve this endpoint at all: it answered a JSON endpoint with HTML (how UniFi OS reports an application it does not have) or rejected the endpoint itself (how UniFi Network reports one the firmware has dropped); unlike `not_found` there is no other identifier or parameter worth trying |
|
|
360
361
|
| 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
|
|
361
362
|
| 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
|
|
362
363
|
| 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
|
|
@@ -336,6 +336,7 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
|
|
|
336
336
|
| 2 | `confirmation_required` | A destructive command ran without `--yes` and without a TTY |
|
|
337
337
|
| 3 | `auth_error` | Authentication error (401/403) |
|
|
338
338
|
| 4 | `not_found` | Not found (404) |
|
|
339
|
+
| 4 | `unsupported` | The controller does not serve this endpoint at all: it answered a JSON endpoint with HTML (how UniFi OS reports an application it does not have) or rejected the endpoint itself (how UniFi Network reports one the firmware has dropped); unlike `not_found` there is no other identifier or parameter worth trying |
|
|
339
340
|
| 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
|
|
340
341
|
| 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
|
|
341
342
|
| 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
|
|
@@ -11,6 +11,72 @@ pub fn error_for_status(status: u16, message: String) -> ApiError {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/// True when a legacy API error means the endpoint itself is not there.
|
|
15
|
+
///
|
|
16
|
+
/// UniFi Network answers an unknown `stat/` path with 404 `api.err.NotFound`
|
|
17
|
+
/// and an unknown `rest/` resource with 400 `api.err.InvalidObject`, verified
|
|
18
|
+
/// against a UniFi OS 9 controller by control: a nonsense resource and
|
|
19
|
+
/// `rest/alarm` produce byte-identical 400s while `rest/networkconf` returns
|
|
20
|
+
/// records.
|
|
21
|
+
///
|
|
22
|
+
/// A 400 alone does not carry that meaning, since the same status reports a
|
|
23
|
+
/// genuinely malformed request, so the marker string is required too and this
|
|
24
|
+
/// is only consulted for a request that carries no caller-supplied body or
|
|
25
|
+
/// parameters that could be the invalid object.
|
|
26
|
+
fn is_absent_legacy_endpoint(err: &ApiError) -> bool {
|
|
27
|
+
match err {
|
|
28
|
+
ApiError::NotFound(_) => true,
|
|
29
|
+
ApiError::Api {
|
|
30
|
+
status: 400,
|
|
31
|
+
message,
|
|
32
|
+
} => message.contains("api.err.InvalidObject"),
|
|
33
|
+
_ => false,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Decode a successful response as JSON, naming what answered when it is not.
|
|
38
|
+
///
|
|
39
|
+
/// UniFi OS answers a request for an application the controller does not have
|
|
40
|
+
/// by proxying it to the web UI, which returns 200 with an HTML page. Decoding
|
|
41
|
+
/// that as JSON produces "error decoding response body", which names neither
|
|
42
|
+
/// the endpoint nor the reason, so the caller cannot tell a missing application
|
|
43
|
+
/// from a transport fault worth retrying.
|
|
44
|
+
///
|
|
45
|
+
/// The body is decoded first and the content type only chooses the error for a
|
|
46
|
+
/// body that did not decode: a controller or proxy that serves JSON under
|
|
47
|
+
/// `text/plain` or under no content type at all is still answering the request,
|
|
48
|
+
/// so it must not be reported as an application that is not there.
|
|
49
|
+
async fn json_or_unsupported<T: DeserializeOwned>(
|
|
50
|
+
resp: reqwest::Response,
|
|
51
|
+
endpoint: &str,
|
|
52
|
+
) -> Result<T, ApiError> {
|
|
53
|
+
let raw = resp
|
|
54
|
+
.headers()
|
|
55
|
+
.get(reqwest::header::CONTENT_TYPE)
|
|
56
|
+
.and_then(|v| v.to_str().ok())
|
|
57
|
+
.unwrap_or("")
|
|
58
|
+
.to_string();
|
|
59
|
+
let bytes = resp.bytes().await?;
|
|
60
|
+
match serde_json::from_slice(&bytes) {
|
|
61
|
+
Ok(value) => Ok(value),
|
|
62
|
+
// A body that claims to be JSON and is not is a fault in a controller
|
|
63
|
+
// that does serve this endpoint, which is a different thing entirely.
|
|
64
|
+
Err(e) if raw.to_ascii_lowercase().contains("json") => Err(ApiError::Other(format!(
|
|
65
|
+
"Failed to decode the response from {endpoint}: {e}"
|
|
66
|
+
))),
|
|
67
|
+
Err(_) => {
|
|
68
|
+
let content_type = match raw.split(';').next().map(str::trim) {
|
|
69
|
+
Some(t) if !t.is_empty() => t.to_string(),
|
|
70
|
+
_ => "no content type".to_string(),
|
|
71
|
+
};
|
|
72
|
+
Err(ApiError::Unsupported {
|
|
73
|
+
endpoint: endpoint.to_string(),
|
|
74
|
+
reason: UnsupportedReason::NotJson { content_type },
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
14
80
|
/// Valid RTSPS quality levels accepted by the Protect API.
|
|
15
81
|
const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
|
|
16
82
|
|
|
@@ -120,18 +186,19 @@ impl UnifiClient {
|
|
|
120
186
|
let body = resp.text().await.unwrap_or_default();
|
|
121
187
|
return Err(error_for_status(status, body));
|
|
122
188
|
}
|
|
123
|
-
|
|
189
|
+
json_or_unsupported(resp, path).await
|
|
124
190
|
}
|
|
125
191
|
|
|
126
192
|
async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
|
|
127
|
-
let
|
|
193
|
+
let endpoint = format!("/proxy/network/api/s/default{path}");
|
|
194
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
128
195
|
let resp = self.http.get(&url).send().await?;
|
|
129
196
|
let status = resp.status().as_u16();
|
|
130
197
|
if !resp.status().is_success() {
|
|
131
198
|
let body = resp.text().await.unwrap_or_default();
|
|
132
199
|
return Err(error_for_status(status, body));
|
|
133
200
|
}
|
|
134
|
-
let legacy: LegacyResponse<T> = resp
|
|
201
|
+
let legacy: LegacyResponse<T> = json_or_unsupported(resp, &endpoint).await?;
|
|
135
202
|
if legacy.meta.rc != "ok" {
|
|
136
203
|
return Err(ApiError::Api {
|
|
137
204
|
status: 200,
|
|
@@ -146,17 +213,15 @@ impl UnifiClient {
|
|
|
146
213
|
manager: &str,
|
|
147
214
|
body: serde_json::Value,
|
|
148
215
|
) -> Result<serde_json::Value, ApiError> {
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
self.base_url
|
|
152
|
-
);
|
|
216
|
+
let endpoint = format!("/proxy/network/api/s/default/cmd/{manager}");
|
|
217
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
153
218
|
let resp = self.http.post(&url).json(&body).send().await?;
|
|
154
219
|
let status = resp.status().as_u16();
|
|
155
220
|
if !resp.status().is_success() {
|
|
156
221
|
let body = resp.text().await.unwrap_or_default();
|
|
157
222
|
return Err(error_for_status(status, body));
|
|
158
223
|
}
|
|
159
|
-
|
|
224
|
+
json_or_unsupported(resp, &endpoint).await
|
|
160
225
|
}
|
|
161
226
|
|
|
162
227
|
async fn put_legacy<T: serde::Serialize>(
|
|
@@ -164,14 +229,15 @@ impl UnifiClient {
|
|
|
164
229
|
path: &str,
|
|
165
230
|
body: &T,
|
|
166
231
|
) -> Result<serde_json::Value, ApiError> {
|
|
167
|
-
let
|
|
232
|
+
let endpoint = format!("/proxy/network/api/s/default{path}");
|
|
233
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
168
234
|
let resp = self.http.put(&url).json(body).send().await?;
|
|
169
235
|
let status = resp.status().as_u16();
|
|
170
236
|
if !resp.status().is_success() {
|
|
171
237
|
let body = resp.text().await.unwrap_or_default();
|
|
172
238
|
return Err(error_for_status(status, body));
|
|
173
239
|
}
|
|
174
|
-
|
|
240
|
+
json_or_unsupported(resp, &endpoint).await
|
|
175
241
|
}
|
|
176
242
|
|
|
177
243
|
async fn post_legacy<T: serde::Serialize>(
|
|
@@ -179,14 +245,15 @@ impl UnifiClient {
|
|
|
179
245
|
path: &str,
|
|
180
246
|
body: &T,
|
|
181
247
|
) -> Result<serde_json::Value, ApiError> {
|
|
182
|
-
let
|
|
248
|
+
let endpoint = format!("/proxy/network/api/s/default{path}");
|
|
249
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
183
250
|
let resp = self.http.post(&url).json(body).send().await?;
|
|
184
251
|
let status = resp.status().as_u16();
|
|
185
252
|
if !resp.status().is_success() {
|
|
186
253
|
let body = resp.text().await.unwrap_or_default();
|
|
187
254
|
return Err(error_for_status(status, body));
|
|
188
255
|
}
|
|
189
|
-
|
|
256
|
+
json_or_unsupported(resp, &endpoint).await
|
|
190
257
|
}
|
|
191
258
|
|
|
192
259
|
// Paginate through all results from Integration API
|
|
@@ -386,21 +453,30 @@ impl UnifiClient {
|
|
|
386
453
|
// `Event` shape, so fall back to it. (The full live event stream on newer
|
|
387
454
|
// controllers is only exposed over the events WebSocket, which this REST
|
|
388
455
|
// client does not consume.)
|
|
456
|
+
//
|
|
457
|
+
// Some UniFi Network 9 builds serve neither. Reporting the fallback's own
|
|
458
|
+
// rejection verbatim would tell a caller that its request was malformed and
|
|
459
|
+
// invite it to retry with different parameters, when in truth the whole
|
|
460
|
+
// event surface is gone and nothing it can send would work.
|
|
389
461
|
pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
.await
|
|
393
|
-
{
|
|
462
|
+
let events_path = format!("/stat/event?_limit={limit}");
|
|
463
|
+
match self.get_legacy::<Event>(&events_path).await {
|
|
394
464
|
Ok(events) => Ok(events),
|
|
395
|
-
Err(ApiError::NotFound(_)) => {
|
|
396
|
-
let mut alarms: Vec<Event> = self.get_legacy("/rest/alarm").await?;
|
|
465
|
+
Err(ApiError::NotFound(_)) => match self.get_legacy::<Event>("/rest/alarm").await {
|
|
397
466
|
// `rest/alarm` is neither time-ordered nor limited server-side;
|
|
398
467
|
// present the most recent `limit` records to match the
|
|
399
468
|
// semantics `stat/event?_limit=` provided on older controllers.
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
469
|
+
Ok(mut alarms) => {
|
|
470
|
+
alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
|
|
471
|
+
alarms.truncate(limit);
|
|
472
|
+
Ok(alarms)
|
|
473
|
+
}
|
|
474
|
+
Err(e) if is_absent_legacy_endpoint(&e) => Err(ApiError::Unsupported {
|
|
475
|
+
endpoint: format!("/proxy/network/api/s/default{events_path}"),
|
|
476
|
+
reason: UnsupportedReason::Removed,
|
|
477
|
+
}),
|
|
478
|
+
Err(e) => Err(e),
|
|
479
|
+
},
|
|
404
480
|
Err(e) => Err(e),
|
|
405
481
|
}
|
|
406
482
|
}
|
|
@@ -467,10 +543,8 @@ impl UnifiClient {
|
|
|
467
543
|
qualities: &[String],
|
|
468
544
|
) -> Result<RtspsStreams, ApiError> {
|
|
469
545
|
validate_qualities(qualities)?;
|
|
470
|
-
let
|
|
471
|
-
|
|
472
|
-
self.base_url
|
|
473
|
-
);
|
|
546
|
+
let endpoint = format!("/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream");
|
|
547
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
474
548
|
let body = serde_json::json!({ "qualities": qualities });
|
|
475
549
|
let resp = self.http.post(&url).json(&body).send().await?;
|
|
476
550
|
let status = resp.status().as_u16();
|
|
@@ -478,7 +552,7 @@ impl UnifiClient {
|
|
|
478
552
|
let body = resp.text().await.unwrap_or_default();
|
|
479
553
|
return Err(error_for_status(status, body));
|
|
480
554
|
}
|
|
481
|
-
|
|
555
|
+
json_or_unsupported(resp, &endpoint).await
|
|
482
556
|
}
|
|
483
557
|
|
|
484
558
|
/// Delete RTSPS streams for a camera at the specified quality levels.
|
|
@@ -509,6 +583,11 @@ impl UnifiClient {
|
|
|
509
583
|
/// Resolve a camera identifier (ID or name) to a camera ID.
|
|
510
584
|
/// If the input is a 24-char hex string, treats it as an ID.
|
|
511
585
|
/// Otherwise, searches by name (case-insensitive).
|
|
586
|
+
///
|
|
587
|
+
/// A name that fits more than one camera is an error rather than a choice
|
|
588
|
+
/// made silently: the caller may be about to delete that camera's streams,
|
|
589
|
+
/// and picking whichever the controller happened to list first would act on
|
|
590
|
+
/// a different camera than the one the user was asked to confirm.
|
|
512
591
|
pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
|
|
513
592
|
// If it looks like a Protect camera ID (24 hex chars), use it directly
|
|
514
593
|
if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
@@ -517,15 +596,30 @@ impl UnifiClient {
|
|
|
517
596
|
// Otherwise, search by name
|
|
518
597
|
let cameras = self.list_protect_cameras().await?;
|
|
519
598
|
let needle = id_or_name.to_lowercase();
|
|
520
|
-
cameras
|
|
599
|
+
let mut matches: Vec<ProtectCamera> = cameras
|
|
521
600
|
.into_iter()
|
|
522
|
-
.
|
|
601
|
+
.filter(|c| {
|
|
523
602
|
c.name
|
|
524
603
|
.as_deref()
|
|
525
604
|
.is_some_and(|n| n.trim().to_lowercase() == needle)
|
|
526
605
|
})
|
|
527
|
-
.
|
|
528
|
-
|
|
606
|
+
.collect();
|
|
607
|
+
|
|
608
|
+
match matches.len() {
|
|
609
|
+
0 => Err(ApiError::NotFound(format!("Camera '{id_or_name}'"))),
|
|
610
|
+
1 => Ok(matches.pop().expect("checked len == 1 above").id),
|
|
611
|
+
_ => {
|
|
612
|
+
let list = matches
|
|
613
|
+
.iter()
|
|
614
|
+
.map(|c| c.id.as_str())
|
|
615
|
+
.collect::<Vec<_>>()
|
|
616
|
+
.join(", ");
|
|
617
|
+
Err(ApiError::Conflict(format!(
|
|
618
|
+
"'{id_or_name}' matches {} cameras: {list}. Use the ID.",
|
|
619
|
+
matches.len()
|
|
620
|
+
)))
|
|
621
|
+
}
|
|
622
|
+
}
|
|
529
623
|
}
|
|
530
624
|
|
|
531
625
|
// System
|
|
@@ -547,7 +641,7 @@ impl UnifiClient {
|
|
|
547
641
|
let body = resp.text().await.unwrap_or_default();
|
|
548
642
|
return Err(error_for_status(status, body));
|
|
549
643
|
}
|
|
550
|
-
|
|
644
|
+
json_or_unsupported(resp, "/api/system").await
|
|
551
645
|
}
|
|
552
646
|
}
|
|
553
647
|
|
|
@@ -637,7 +731,8 @@ impl ProtectSession {
|
|
|
637
731
|
|
|
638
732
|
/// GET from the direct Protect API (cookie-authenticated).
|
|
639
733
|
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
|
|
640
|
-
let
|
|
734
|
+
let endpoint = format!("/proxy/protect/api{path}");
|
|
735
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
641
736
|
let mut req = self
|
|
642
737
|
.http
|
|
643
738
|
.get(&url)
|
|
@@ -651,9 +746,7 @@ impl ProtectSession {
|
|
|
651
746
|
let body = resp.text().await.unwrap_or_default();
|
|
652
747
|
return Err(error_for_status(status, body));
|
|
653
748
|
}
|
|
654
|
-
|
|
655
|
-
serde_json::from_slice(&bytes)
|
|
656
|
-
.map_err(|e| ApiError::Other(format!("JSON parse error: {e}")))
|
|
749
|
+
json_or_unsupported(resp, &endpoint).await
|
|
657
750
|
}
|
|
658
751
|
|
|
659
752
|
/// List all cameras from the direct Protect API (full objects).
|
|
@@ -7,6 +7,6 @@ pub use client::UnifiClient;
|
|
|
7
7
|
pub use client::error_for_status;
|
|
8
8
|
pub use types::{
|
|
9
9
|
ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LastConnection,
|
|
10
|
-
LegacyClient, LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo,
|
|
11
|
-
format_mac, format_uptime, normalize_mac,
|
|
10
|
+
LegacyClient, LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, UnsupportedReason,
|
|
11
|
+
format_bytes, format_mac, format_uptime, normalize_mac,
|
|
12
12
|
};
|
|
@@ -172,8 +172,8 @@ fn deserialize_paginated_response() {
|
|
|
172
172
|
"count": 2,
|
|
173
173
|
"totalCount": 2,
|
|
174
174
|
"data": [
|
|
175
|
-
{"macAddress": "aa:bb:cc:dd:ee:ff", "ipAddress": "
|
|
176
|
-
{"macAddress": "11:22:33:44:55:66", "ipAddress": "
|
|
175
|
+
{"macAddress": "aa:bb:cc:dd:ee:ff", "ipAddress": "192.0.2.1", "name": "Test", "type": "WIRED"},
|
|
176
|
+
{"macAddress": "11:22:33:44:55:66", "ipAddress": "192.0.2.2", "hostname": "host2", "type": "WIRELESS"}
|
|
177
177
|
]
|
|
178
178
|
}"#;
|
|
179
179
|
let resp: PaginatedResponse<Client> = serde_json::from_str(json).unwrap();
|
|
@@ -210,7 +210,7 @@ fn deserialize_legacy_response_ok() {
|
|
|
210
210
|
let json = r#"{
|
|
211
211
|
"meta": {"rc": "ok"},
|
|
212
212
|
"data": [
|
|
213
|
-
{"_id": "abc123", "mac": "aa:bb:cc:dd:ee:ff", "ip": "
|
|
213
|
+
{"_id": "abc123", "mac": "aa:bb:cc:dd:ee:ff", "ip": "192.0.2.1", "is_wired": true}
|
|
214
214
|
]
|
|
215
215
|
}"#;
|
|
216
216
|
let resp: LegacyResponse<LegacyClient> = serde_json::from_str(json).unwrap();
|
|
@@ -237,15 +237,15 @@ fn deserialize_legacy_response_error() {
|
|
|
237
237
|
#[test]
|
|
238
238
|
fn deserialize_client_all_fields() {
|
|
239
239
|
let json = r#"{
|
|
240
|
-
"macAddress": "
|
|
241
|
-
"ipAddress": "
|
|
240
|
+
"macAddress": "aa:bb:cc:dd:d5:54",
|
|
241
|
+
"ipAddress": "198.51.100.180",
|
|
242
242
|
"name": "Mac Mini",
|
|
243
243
|
"hostname": "mac-mini",
|
|
244
244
|
"type": "WIRED"
|
|
245
245
|
}"#;
|
|
246
246
|
let client: Client = serde_json::from_str(json).unwrap();
|
|
247
|
-
assert_eq!(client.mac_address.as_deref(), Some("
|
|
248
|
-
assert_eq!(client.ip_address.as_deref(), Some("
|
|
247
|
+
assert_eq!(client.mac_address.as_deref(), Some("aa:bb:cc:dd:d5:54"));
|
|
248
|
+
assert_eq!(client.ip_address.as_deref(), Some("198.51.100.180"));
|
|
249
249
|
assert_eq!(client.name.as_deref(), Some("Mac Mini"));
|
|
250
250
|
assert_eq!(client.hostname.as_deref(), Some("mac-mini"));
|
|
251
251
|
assert_eq!(client.client_type.as_deref(), Some("WIRED"));
|
|
@@ -290,7 +290,7 @@ fn deserialize_legacy_client_full() {
|
|
|
290
290
|
let json = r#"{
|
|
291
291
|
"_id": "67890",
|
|
292
292
|
"mac": "aa:bb:cc:dd:ee:ff",
|
|
293
|
-
"ip": "
|
|
293
|
+
"ip": "192.0.2.5",
|
|
294
294
|
"hostname": "myhost",
|
|
295
295
|
"name": "My Client",
|
|
296
296
|
"is_wired": false,
|
|
@@ -298,8 +298,8 @@ fn deserialize_legacy_client_full() {
|
|
|
298
298
|
"tx_bytes": 1048576,
|
|
299
299
|
"rx_bytes": 2097152,
|
|
300
300
|
"signal": -55,
|
|
301
|
-
"ap_mac": "
|
|
302
|
-
"essid": "
|
|
301
|
+
"ap_mac": "aa:bb:cc:dd:b8:00",
|
|
302
|
+
"essid": "GuestNet"
|
|
303
303
|
}"#;
|
|
304
304
|
let client: LegacyClient = serde_json::from_str(json).unwrap();
|
|
305
305
|
assert_eq!(client.id, "67890");
|
|
@@ -309,8 +309,8 @@ fn deserialize_legacy_client_full() {
|
|
|
309
309
|
assert_eq!(client.tx_bytes, Some(1048576));
|
|
310
310
|
assert_eq!(client.rx_bytes, Some(2097152));
|
|
311
311
|
assert_eq!(client.signal, Some(-55));
|
|
312
|
-
assert_eq!(client.ap_mac.as_deref(), Some("
|
|
313
|
-
assert_eq!(client.ssid.as_deref(), Some("
|
|
312
|
+
assert_eq!(client.ap_mac.as_deref(), Some("aa:bb:cc:dd:b8:00"));
|
|
313
|
+
assert_eq!(client.ssid.as_deref(), Some("GuestNet"));
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
#[test]
|
|
@@ -351,15 +351,15 @@ fn deserialize_site() {
|
|
|
351
351
|
#[test]
|
|
352
352
|
fn deserialize_device() {
|
|
353
353
|
let json = r#"{
|
|
354
|
-
"macAddress": "
|
|
355
|
-
"ipAddress": "
|
|
354
|
+
"macAddress": "aa:bb:cc:dd:06:43",
|
|
355
|
+
"ipAddress": "198.51.100.1",
|
|
356
356
|
"name": "UCG Ultra",
|
|
357
357
|
"model": "UCG Ultra",
|
|
358
358
|
"state": "ONLINE",
|
|
359
359
|
"firmwareVersion": "5.0.12"
|
|
360
360
|
}"#;
|
|
361
361
|
let device: Device = serde_json::from_str(json).unwrap();
|
|
362
|
-
assert_eq!(device.mac_address.as_deref(), Some("
|
|
362
|
+
assert_eq!(device.mac_address.as_deref(), Some("aa:bb:cc:dd:06:43"));
|
|
363
363
|
assert_eq!(device.name.as_deref(), Some("UCG Ultra"));
|
|
364
364
|
assert_eq!(device.state.as_deref(), Some("ONLINE"));
|
|
365
365
|
assert_eq!(device.firmware_version.as_deref(), Some("5.0.12"));
|
|
@@ -406,14 +406,14 @@ fn deserialize_health_wan() {
|
|
|
406
406
|
let json = r#"{
|
|
407
407
|
"subsystem": "wan",
|
|
408
408
|
"status": "ok",
|
|
409
|
-
"wan_ip": "
|
|
410
|
-
"isp_name": "
|
|
409
|
+
"wan_ip": "203.0.113.156",
|
|
410
|
+
"isp_name": "ExampleISP NL"
|
|
411
411
|
}"#;
|
|
412
412
|
let health: HealthSubsystem = serde_json::from_str(json).unwrap();
|
|
413
413
|
assert_eq!(health.subsystem, "wan");
|
|
414
414
|
assert_eq!(health.status.as_deref(), Some("ok"));
|
|
415
|
-
assert_eq!(health.wan_ip.as_deref(), Some("
|
|
416
|
-
assert_eq!(health.isp_name.as_deref(), Some("
|
|
415
|
+
assert_eq!(health.wan_ip.as_deref(), Some("203.0.113.156"));
|
|
416
|
+
assert_eq!(health.isp_name.as_deref(), Some("ExampleISP NL"));
|
|
417
417
|
}
|
|
418
418
|
|
|
419
419
|
#[test]
|
|
@@ -472,7 +472,7 @@ fn deserialize_sysinfo_minimal() {
|
|
|
472
472
|
fn deserialize_event() {
|
|
473
473
|
let json = r#"{
|
|
474
474
|
"key": "EVT_AP_Connected",
|
|
475
|
-
"msg": "AP[
|
|
475
|
+
"msg": "AP[aa:bb:cc:dd:47:ab] was connected",
|
|
476
476
|
"subsystem": "wlan",
|
|
477
477
|
"time": 1710886800,
|
|
478
478
|
"datetime": "2026-03-19T12:00:00Z"
|
|
@@ -554,7 +554,7 @@ fn deserialize_port_entry_minimal() {
|
|
|
554
554
|
#[test]
|
|
555
555
|
fn deserialize_device_with_ports() {
|
|
556
556
|
let json = r#"{
|
|
557
|
-
"mac": "
|
|
557
|
+
"mac": "aa:bb:cc:dd:06:43",
|
|
558
558
|
"name": "USW-24-PoE",
|
|
559
559
|
"model": "USW-24-PoE",
|
|
560
560
|
"port_table": [
|
|
@@ -581,8 +581,8 @@ fn deserialize_device_with_empty_port_table() {
|
|
|
581
581
|
#[test]
|
|
582
582
|
fn deserialize_legacy_device() {
|
|
583
583
|
let json = r#"{
|
|
584
|
-
"mac": "
|
|
585
|
-
"ip": "
|
|
584
|
+
"mac": "aa:bb:cc:dd:06:43",
|
|
585
|
+
"ip": "198.51.100.1",
|
|
586
586
|
"name": "UCG Ultra",
|
|
587
587
|
"model": "UCG Ultra",
|
|
588
588
|
"type": "ugw",
|
|
@@ -592,7 +592,7 @@ fn deserialize_legacy_device() {
|
|
|
592
592
|
"num_sta": 42
|
|
593
593
|
}"#;
|
|
594
594
|
let device: LegacyDevice = serde_json::from_str(json).unwrap();
|
|
595
|
-
assert_eq!(device.mac.as_deref(), Some("
|
|
595
|
+
assert_eq!(device.mac.as_deref(), Some("aa:bb:cc:dd:06:43"));
|
|
596
596
|
assert_eq!(device.name.as_deref(), Some("UCG Ultra"));
|
|
597
597
|
assert_eq!(device.state, Some(1));
|
|
598
598
|
assert_eq!(device.state_str(), "ONLINE");
|
|
@@ -650,7 +650,7 @@ fn legacy_device_upgradable_false_explicit() {
|
|
|
650
650
|
fn host_system_update_available() {
|
|
651
651
|
let json = r#"{"deviceState": "updateAvailable", "name": "UCG Ultra"}"#;
|
|
652
652
|
let host: HostSystem = serde_json::from_str(json).unwrap();
|
|
653
|
-
|
|
653
|
+
assert_eq!(host.update_available(), Some(true));
|
|
654
654
|
assert_eq!(host.name.as_deref(), Some("UCG Ultra"));
|
|
655
655
|
}
|
|
656
656
|
|
|
@@ -658,14 +658,18 @@ fn host_system_update_available() {
|
|
|
658
658
|
fn host_system_no_update() {
|
|
659
659
|
let json = r#"{"deviceState": "online"}"#;
|
|
660
660
|
let host: HostSystem = serde_json::from_str(json).unwrap();
|
|
661
|
-
|
|
661
|
+
assert_eq!(host.update_available(), Some(false));
|
|
662
662
|
}
|
|
663
663
|
|
|
664
664
|
#[test]
|
|
665
|
-
fn
|
|
665
|
+
fn host_system_missing_state_is_unknown_not_up_to_date() {
|
|
666
666
|
let json = r#"{}"#;
|
|
667
667
|
let host: HostSystem = serde_json::from_str(json).unwrap();
|
|
668
|
-
|
|
668
|
+
assert_eq!(
|
|
669
|
+
host.update_available(),
|
|
670
|
+
None,
|
|
671
|
+
"a host that reported no device state has not reported an up-to-date one"
|
|
672
|
+
);
|
|
669
673
|
}
|
|
670
674
|
|
|
671
675
|
// --- strip_mac_suffix ---
|
|
@@ -674,8 +678,8 @@ fn host_system_missing_state() {
|
|
|
674
678
|
fn strip_mac_suffix_removes_colon_suffix() {
|
|
675
679
|
// MAC aa:bb:cc:dd:ee:ff → suffix " ee:ff"
|
|
676
680
|
assert_eq!(
|
|
677
|
-
strip_mac_suffix("
|
|
678
|
-
"
|
|
681
|
+
strip_mac_suffix("lobby-bluetooth-proxy ee:ff", Some("aa:bb:cc:dd:ee:ff")),
|
|
682
|
+
"lobby-bluetooth-proxy"
|
|
679
683
|
);
|
|
680
684
|
}
|
|
681
685
|
|
|
@@ -683,8 +687,8 @@ fn strip_mac_suffix_removes_colon_suffix() {
|
|
|
683
687
|
fn strip_mac_suffix_removes_no_colon_suffix() {
|
|
684
688
|
// Also matches " eeff" without colon
|
|
685
689
|
assert_eq!(
|
|
686
|
-
strip_mac_suffix("
|
|
687
|
-
"
|
|
690
|
+
strip_mac_suffix("lobby-bluetooth-proxy eeff", Some("aa:bb:cc:dd:ee:ff")),
|
|
691
|
+
"lobby-bluetooth-proxy"
|
|
688
692
|
);
|
|
689
693
|
}
|
|
690
694
|
|