unifi-cli 0.3.2__tar.gz → 0.3.3__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.3}/CHANGELOG.md +8 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/Cargo.lock +1 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/Cargo.toml +1 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/PKG-INFO +2 -1
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/README.md +1 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/client.rs +63 -22
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/tests.rs +28 -28
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/types.rs +27 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/clients.rs +34 -34
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/ports.rs +6 -6
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/main.rs +6 -6
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/output.rs +52 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/schema.rs +6 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/tui.rs +21 -21
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/mock_server.rs +327 -75
- unifi_cli-0.3.3/tests/no_real_network_data.rs +245 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/.github/workflows/release.yml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/.gitignore +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/LICENSE +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/Makefile +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/prek.toml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/pyproject.toml +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/mod.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/devices.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/events.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/mod.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/networks.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/protect.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/system.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/fields.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/lib.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/cli_contract.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/fixtures/clispec-v0.2.json +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/spec_compliance.rs +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/unifi_cli/__init__.py +0 -0
- {unifi_cli-0.3.2 → unifi_cli-0.3.3}/unifi_cli/__main__.py +0 -0
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.3](https://github.com/rvben/unifi-cli/compare/v0.3.2...v0.3.3) - 2026-08-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **api**: decode a response body before judging its content type ([0e49404](https://github.com/rvben/unifi-cli/commit/0e4940467e428f4cea20f54c1eafbc337b6cca00))
|
|
10
|
+
- **api**: report an absent controller application as unsupported ([0f5384c](https://github.com/rvben/unifi-cli/commit/0f5384c84caccc3dfcbf5b5b63831cd97eca55b5))
|
|
11
|
+
|
|
5
12
|
## [0.3.2](https://github.com/rvben/unifi-cli/compare/v0.3.1...v0.3.2) - 2026-08-10
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
@@ -130,7 +137,7 @@ Thanks to [@l3wi](https://github.com/l3wi) for reporting the TLS certificate val
|
|
|
130
137
|
- TUI: device panel shows all devices without scrolling, cursor moves through items
|
|
131
138
|
- TUI: client list viewport follows cursor instead of cursor staying at top
|
|
132
139
|
- CLI: dynamic column widths adapt to data instead of fixed widths causing misalignment
|
|
133
|
-
- CLI: MAC suffixes stripped from display names (e.g., "
|
|
140
|
+
- CLI: MAC suffixes stripped from display names (e.g., "lobby-bluetooth-proxy" instead of "lobby-bluetooth-proxy 43:3c")
|
|
134
141
|
|
|
135
142
|
### Changed
|
|
136
143
|
- TUI: overlay shortcut hints moved from footer to overlay bottom border
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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 API at all (it answered a JSON endpoint with HTML, which is how UniFi OS reports an application it does not have); unlike `not_found` there is no other identifier 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 API at all (it answered a JSON endpoint with HTML, which is how UniFi OS reports an application it does not have); unlike `not_found` there is no other identifier 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,49 @@ pub fn error_for_status(status: u16, message: String) -> ApiError {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/// Decode a successful response as JSON, naming what answered when it is not.
|
|
15
|
+
///
|
|
16
|
+
/// UniFi OS answers a request for an application the controller does not have
|
|
17
|
+
/// by proxying it to the web UI, which returns 200 with an HTML page. Decoding
|
|
18
|
+
/// that as JSON produces "error decoding response body", which names neither
|
|
19
|
+
/// the endpoint nor the reason, so the caller cannot tell a missing application
|
|
20
|
+
/// from a transport fault worth retrying.
|
|
21
|
+
///
|
|
22
|
+
/// The body is decoded first and the content type only chooses the error for a
|
|
23
|
+
/// body that did not decode: a controller or proxy that serves JSON under
|
|
24
|
+
/// `text/plain` or under no content type at all is still answering the request,
|
|
25
|
+
/// so it must not be reported as an application that is not there.
|
|
26
|
+
async fn json_or_unsupported<T: DeserializeOwned>(
|
|
27
|
+
resp: reqwest::Response,
|
|
28
|
+
endpoint: &str,
|
|
29
|
+
) -> Result<T, ApiError> {
|
|
30
|
+
let raw = resp
|
|
31
|
+
.headers()
|
|
32
|
+
.get(reqwest::header::CONTENT_TYPE)
|
|
33
|
+
.and_then(|v| v.to_str().ok())
|
|
34
|
+
.unwrap_or("")
|
|
35
|
+
.to_string();
|
|
36
|
+
let bytes = resp.bytes().await?;
|
|
37
|
+
match serde_json::from_slice(&bytes) {
|
|
38
|
+
Ok(value) => Ok(value),
|
|
39
|
+
// A body that claims to be JSON and is not is a fault in a controller
|
|
40
|
+
// that does serve this endpoint, which is a different thing entirely.
|
|
41
|
+
Err(e) if raw.to_ascii_lowercase().contains("json") => Err(ApiError::Other(format!(
|
|
42
|
+
"Failed to decode the response from {endpoint}: {e}"
|
|
43
|
+
))),
|
|
44
|
+
Err(_) => {
|
|
45
|
+
let content_type = match raw.split(';').next().map(str::trim) {
|
|
46
|
+
Some(t) if !t.is_empty() => t.to_string(),
|
|
47
|
+
_ => "no content type".to_string(),
|
|
48
|
+
};
|
|
49
|
+
Err(ApiError::Unsupported {
|
|
50
|
+
endpoint: endpoint.to_string(),
|
|
51
|
+
content_type,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
14
57
|
/// Valid RTSPS quality levels accepted by the Protect API.
|
|
15
58
|
const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
|
|
16
59
|
|
|
@@ -120,18 +163,19 @@ impl UnifiClient {
|
|
|
120
163
|
let body = resp.text().await.unwrap_or_default();
|
|
121
164
|
return Err(error_for_status(status, body));
|
|
122
165
|
}
|
|
123
|
-
|
|
166
|
+
json_or_unsupported(resp, path).await
|
|
124
167
|
}
|
|
125
168
|
|
|
126
169
|
async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
|
|
127
|
-
let
|
|
170
|
+
let endpoint = format!("/proxy/network/api/s/default{path}");
|
|
171
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
128
172
|
let resp = self.http.get(&url).send().await?;
|
|
129
173
|
let status = resp.status().as_u16();
|
|
130
174
|
if !resp.status().is_success() {
|
|
131
175
|
let body = resp.text().await.unwrap_or_default();
|
|
132
176
|
return Err(error_for_status(status, body));
|
|
133
177
|
}
|
|
134
|
-
let legacy: LegacyResponse<T> = resp
|
|
178
|
+
let legacy: LegacyResponse<T> = json_or_unsupported(resp, &endpoint).await?;
|
|
135
179
|
if legacy.meta.rc != "ok" {
|
|
136
180
|
return Err(ApiError::Api {
|
|
137
181
|
status: 200,
|
|
@@ -146,17 +190,15 @@ impl UnifiClient {
|
|
|
146
190
|
manager: &str,
|
|
147
191
|
body: serde_json::Value,
|
|
148
192
|
) -> Result<serde_json::Value, ApiError> {
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
self.base_url
|
|
152
|
-
);
|
|
193
|
+
let endpoint = format!("/proxy/network/api/s/default/cmd/{manager}");
|
|
194
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
153
195
|
let resp = self.http.post(&url).json(&body).send().await?;
|
|
154
196
|
let status = resp.status().as_u16();
|
|
155
197
|
if !resp.status().is_success() {
|
|
156
198
|
let body = resp.text().await.unwrap_or_default();
|
|
157
199
|
return Err(error_for_status(status, body));
|
|
158
200
|
}
|
|
159
|
-
|
|
201
|
+
json_or_unsupported(resp, &endpoint).await
|
|
160
202
|
}
|
|
161
203
|
|
|
162
204
|
async fn put_legacy<T: serde::Serialize>(
|
|
@@ -164,14 +206,15 @@ impl UnifiClient {
|
|
|
164
206
|
path: &str,
|
|
165
207
|
body: &T,
|
|
166
208
|
) -> Result<serde_json::Value, ApiError> {
|
|
167
|
-
let
|
|
209
|
+
let endpoint = format!("/proxy/network/api/s/default{path}");
|
|
210
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
168
211
|
let resp = self.http.put(&url).json(body).send().await?;
|
|
169
212
|
let status = resp.status().as_u16();
|
|
170
213
|
if !resp.status().is_success() {
|
|
171
214
|
let body = resp.text().await.unwrap_or_default();
|
|
172
215
|
return Err(error_for_status(status, body));
|
|
173
216
|
}
|
|
174
|
-
|
|
217
|
+
json_or_unsupported(resp, &endpoint).await
|
|
175
218
|
}
|
|
176
219
|
|
|
177
220
|
async fn post_legacy<T: serde::Serialize>(
|
|
@@ -179,14 +222,15 @@ impl UnifiClient {
|
|
|
179
222
|
path: &str,
|
|
180
223
|
body: &T,
|
|
181
224
|
) -> Result<serde_json::Value, ApiError> {
|
|
182
|
-
let
|
|
225
|
+
let endpoint = format!("/proxy/network/api/s/default{path}");
|
|
226
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
183
227
|
let resp = self.http.post(&url).json(body).send().await?;
|
|
184
228
|
let status = resp.status().as_u16();
|
|
185
229
|
if !resp.status().is_success() {
|
|
186
230
|
let body = resp.text().await.unwrap_or_default();
|
|
187
231
|
return Err(error_for_status(status, body));
|
|
188
232
|
}
|
|
189
|
-
|
|
233
|
+
json_or_unsupported(resp, &endpoint).await
|
|
190
234
|
}
|
|
191
235
|
|
|
192
236
|
// Paginate through all results from Integration API
|
|
@@ -467,10 +511,8 @@ impl UnifiClient {
|
|
|
467
511
|
qualities: &[String],
|
|
468
512
|
) -> Result<RtspsStreams, ApiError> {
|
|
469
513
|
validate_qualities(qualities)?;
|
|
470
|
-
let
|
|
471
|
-
|
|
472
|
-
self.base_url
|
|
473
|
-
);
|
|
514
|
+
let endpoint = format!("/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream");
|
|
515
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
474
516
|
let body = serde_json::json!({ "qualities": qualities });
|
|
475
517
|
let resp = self.http.post(&url).json(&body).send().await?;
|
|
476
518
|
let status = resp.status().as_u16();
|
|
@@ -478,7 +520,7 @@ impl UnifiClient {
|
|
|
478
520
|
let body = resp.text().await.unwrap_or_default();
|
|
479
521
|
return Err(error_for_status(status, body));
|
|
480
522
|
}
|
|
481
|
-
|
|
523
|
+
json_or_unsupported(resp, &endpoint).await
|
|
482
524
|
}
|
|
483
525
|
|
|
484
526
|
/// Delete RTSPS streams for a camera at the specified quality levels.
|
|
@@ -547,7 +589,7 @@ impl UnifiClient {
|
|
|
547
589
|
let body = resp.text().await.unwrap_or_default();
|
|
548
590
|
return Err(error_for_status(status, body));
|
|
549
591
|
}
|
|
550
|
-
|
|
592
|
+
json_or_unsupported(resp, "/api/system").await
|
|
551
593
|
}
|
|
552
594
|
}
|
|
553
595
|
|
|
@@ -637,7 +679,8 @@ impl ProtectSession {
|
|
|
637
679
|
|
|
638
680
|
/// GET from the direct Protect API (cookie-authenticated).
|
|
639
681
|
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
|
|
640
|
-
let
|
|
682
|
+
let endpoint = format!("/proxy/protect/api{path}");
|
|
683
|
+
let url = format!("{}{endpoint}", self.base_url);
|
|
641
684
|
let mut req = self
|
|
642
685
|
.http
|
|
643
686
|
.get(&url)
|
|
@@ -651,9 +694,7 @@ impl ProtectSession {
|
|
|
651
694
|
let body = resp.text().await.unwrap_or_default();
|
|
652
695
|
return Err(error_for_status(status, body));
|
|
653
696
|
}
|
|
654
|
-
|
|
655
|
-
serde_json::from_slice(&bytes)
|
|
656
|
-
.map_err(|e| ApiError::Other(format!("JSON parse error: {e}")))
|
|
697
|
+
json_or_unsupported(resp, &endpoint).await
|
|
657
698
|
}
|
|
658
699
|
|
|
659
700
|
/// List all cameras from the direct Protect API (full objects).
|
|
@@ -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");
|
|
@@ -674,8 +674,8 @@ fn host_system_missing_state() {
|
|
|
674
674
|
fn strip_mac_suffix_removes_colon_suffix() {
|
|
675
675
|
// MAC aa:bb:cc:dd:ee:ff → suffix " ee:ff"
|
|
676
676
|
assert_eq!(
|
|
677
|
-
strip_mac_suffix("
|
|
678
|
-
"
|
|
677
|
+
strip_mac_suffix("lobby-bluetooth-proxy ee:ff", Some("aa:bb:cc:dd:ee:ff")),
|
|
678
|
+
"lobby-bluetooth-proxy"
|
|
679
679
|
);
|
|
680
680
|
}
|
|
681
681
|
|
|
@@ -683,8 +683,8 @@ fn strip_mac_suffix_removes_colon_suffix() {
|
|
|
683
683
|
fn strip_mac_suffix_removes_no_colon_suffix() {
|
|
684
684
|
// Also matches " eeff" without colon
|
|
685
685
|
assert_eq!(
|
|
686
|
-
strip_mac_suffix("
|
|
687
|
-
"
|
|
686
|
+
strip_mac_suffix("lobby-bluetooth-proxy eeff", Some("aa:bb:cc:dd:ee:ff")),
|
|
687
|
+
"lobby-bluetooth-proxy"
|
|
688
688
|
);
|
|
689
689
|
}
|
|
690
690
|
|
|
@@ -528,6 +528,15 @@ pub enum ApiError {
|
|
|
528
528
|
/// rejected locally before any HTTP call. Published by `unifi schema`
|
|
529
529
|
/// as kind `conflict`, exit code 6.
|
|
530
530
|
Conflict(String),
|
|
531
|
+
/// The controller answered a JSON endpoint with something else, which is
|
|
532
|
+
/// how UniFi OS reports an application it does not have: the request is
|
|
533
|
+
/// proxied to the web UI and returns 200 with an HTML page. Distinct from
|
|
534
|
+
/// `NotFound` because the whole API is absent, not one record, so there is
|
|
535
|
+
/// no other identifier worth trying. Published as kind `unsupported`.
|
|
536
|
+
Unsupported {
|
|
537
|
+
endpoint: String,
|
|
538
|
+
content_type: String,
|
|
539
|
+
},
|
|
531
540
|
Other(String),
|
|
532
541
|
}
|
|
533
542
|
|
|
@@ -611,6 +620,24 @@ impl fmt::Display for ApiError {
|
|
|
611
620
|
)
|
|
612
621
|
}
|
|
613
622
|
ApiError::Conflict(msg) => write!(f, "{msg}"),
|
|
623
|
+
ApiError::Unsupported {
|
|
624
|
+
endpoint,
|
|
625
|
+
content_type,
|
|
626
|
+
} => {
|
|
627
|
+
write!(
|
|
628
|
+
f,
|
|
629
|
+
"This controller does not serve {endpoint}: it answered with {content_type} \
|
|
630
|
+
instead of JSON"
|
|
631
|
+
)?;
|
|
632
|
+
if endpoint.contains("/protect/") {
|
|
633
|
+
write!(
|
|
634
|
+
f,
|
|
635
|
+
"\n Hint: UniFi OS proxies the request to its web UI when the Protect \
|
|
636
|
+
application is not installed on the controller"
|
|
637
|
+
)?;
|
|
638
|
+
}
|
|
639
|
+
Ok(())
|
|
640
|
+
}
|
|
614
641
|
ApiError::Other(msg) => write!(f, "{msg}"),
|
|
615
642
|
}
|
|
616
643
|
}
|
|
@@ -495,33 +495,33 @@ mod tests {
|
|
|
495
495
|
fn merge_joins_legacy_detail_by_mac() {
|
|
496
496
|
let clients = vec![make_client(
|
|
497
497
|
"Plug",
|
|
498
|
-
"
|
|
499
|
-
"
|
|
498
|
+
"aa:bb:cc:dd:3c:29",
|
|
499
|
+
"192.0.2.202",
|
|
500
500
|
"WIRELESS",
|
|
501
501
|
)];
|
|
502
502
|
let legacy_records = vec![legacy(
|
|
503
|
-
r#"{"_id":"1","mac":"
|
|
503
|
+
r#"{"_id":"1","mac":"aa:bb:cc:dd:3c:29","ip":"192.0.2.202","essid":"GuestNet",
|
|
504
504
|
"signal":-57,"uptime":321125,"network":"IoT","vlan":20}"#,
|
|
505
505
|
)];
|
|
506
506
|
|
|
507
507
|
let rows = merge_rows(clients, legacy_records);
|
|
508
508
|
assert_eq!(rows.len(), 1);
|
|
509
509
|
let json = rows[0].to_json();
|
|
510
|
-
assert_eq!(json["ssid"], "
|
|
510
|
+
assert_eq!(json["ssid"], "GuestNet");
|
|
511
511
|
assert_eq!(json["signal"], -57);
|
|
512
512
|
assert_eq!(json["network"], "IoT");
|
|
513
513
|
assert_eq!(json["vlan"], 20);
|
|
514
|
-
assert_eq!(json["ip"], "
|
|
514
|
+
assert_eq!(json["ip"], "192.0.2.202");
|
|
515
515
|
}
|
|
516
516
|
|
|
517
517
|
#[test]
|
|
518
518
|
fn merge_matches_macs_in_different_formats() {
|
|
519
|
-
let clients = vec![make_client("X", "AA-BB-CC-DD-EE-FF", "
|
|
519
|
+
let clients = vec![make_client("X", "AA-BB-CC-DD-EE-FF", "192.0.2.1", "WIRED")];
|
|
520
520
|
let legacy_records = vec![legacy(
|
|
521
|
-
r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:ff","ip":"
|
|
521
|
+
r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:ff","ip":"192.0.2.5"}"#,
|
|
522
522
|
)];
|
|
523
523
|
let rows = merge_rows(clients, legacy_records);
|
|
524
|
-
assert_eq!(rows[0].live_ip(), Some("
|
|
524
|
+
assert_eq!(rows[0].live_ip(), Some("192.0.2.5"));
|
|
525
525
|
}
|
|
526
526
|
|
|
527
527
|
/// The defect this guards: `clients list` read `ip` from the integration API,
|
|
@@ -532,12 +532,12 @@ mod tests {
|
|
|
532
532
|
fn live_ip_wins_over_the_integration_apis_last_known_address() {
|
|
533
533
|
let clients = vec![make_client(
|
|
534
534
|
"Refrigerator",
|
|
535
|
-
"
|
|
536
|
-
"
|
|
535
|
+
"aa:bb:cc:dd:07:e6",
|
|
536
|
+
"192.0.2.203",
|
|
537
537
|
"WIRELESS",
|
|
538
538
|
)];
|
|
539
539
|
let legacy_records = vec![legacy(
|
|
540
|
-
r#"{"_id":"1","mac":"
|
|
540
|
+
r#"{"_id":"1","mac":"aa:bb:cc:dd:07:e6","essid":"Network"}"#,
|
|
541
541
|
)];
|
|
542
542
|
|
|
543
543
|
let rows = merge_rows(clients, legacy_records);
|
|
@@ -555,7 +555,7 @@ mod tests {
|
|
|
555
555
|
let clients = vec![make_client(
|
|
556
556
|
"Ghost",
|
|
557
557
|
"aa:bb:cc:dd:ee:ff",
|
|
558
|
-
"
|
|
558
|
+
"192.0.2.1",
|
|
559
559
|
"WIRED",
|
|
560
560
|
)];
|
|
561
561
|
let rows = merge_rows(clients, vec![]);
|
|
@@ -570,12 +570,12 @@ mod tests {
|
|
|
570
570
|
#[test]
|
|
571
571
|
fn merge_preserves_client_order_and_count() {
|
|
572
572
|
let clients = vec![
|
|
573
|
-
make_client("A", "aa:bb:cc:dd:ee:01", "
|
|
574
|
-
make_client("B", "aa:bb:cc:dd:ee:02", "
|
|
575
|
-
make_client("C", "aa:bb:cc:dd:ee:03", "
|
|
573
|
+
make_client("A", "aa:bb:cc:dd:ee:01", "192.0.2.1", "WIRED"),
|
|
574
|
+
make_client("B", "aa:bb:cc:dd:ee:02", "192.0.2.2", "WIRELESS"),
|
|
575
|
+
make_client("C", "aa:bb:cc:dd:ee:03", "192.0.2.3", "WIRELESS"),
|
|
576
576
|
];
|
|
577
577
|
let legacy_records = vec![legacy(
|
|
578
|
-
r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:02","ip":"
|
|
578
|
+
r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:02","ip":"192.0.2.104"}"#,
|
|
579
579
|
)];
|
|
580
580
|
let rows = merge_rows(clients, legacy_records);
|
|
581
581
|
let names: Vec<String> = rows
|
|
@@ -583,26 +583,26 @@ mod tests {
|
|
|
583
583
|
.map(|r| r.client.clean_name())
|
|
584
584
|
.collect::<Vec<_>>();
|
|
585
585
|
assert_eq!(names, vec!["A", "B", "C"]);
|
|
586
|
-
assert_eq!(rows[1].live_ip(), Some("
|
|
586
|
+
assert_eq!(rows[1].live_ip(), Some("192.0.2.104"));
|
|
587
587
|
assert_eq!(rows[0].live_ip(), None);
|
|
588
588
|
}
|
|
589
589
|
|
|
590
590
|
#[test]
|
|
591
591
|
fn merge_ignores_legacy_records_with_no_matching_client() {
|
|
592
|
-
let clients = vec![make_client("A", "aa:bb:cc:dd:ee:01", "
|
|
592
|
+
let clients = vec![make_client("A", "aa:bb:cc:dd:ee:01", "192.0.2.1", "WIRED")];
|
|
593
593
|
let legacy_records = vec![
|
|
594
|
-
legacy(r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:01","ip":"
|
|
595
|
-
legacy(r#"{"_id":"2","mac":"ff:ff:ff:ff:ff:ff","ip":"
|
|
594
|
+
legacy(r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:01","ip":"192.0.2.101"}"#),
|
|
595
|
+
legacy(r#"{"_id":"2","mac":"ff:ff:ff:ff:ff:ff","ip":"192.0.2.102"}"#),
|
|
596
596
|
];
|
|
597
597
|
let rows = merge_rows(clients, legacy_records);
|
|
598
598
|
assert_eq!(rows.len(), 1);
|
|
599
|
-
assert_eq!(rows[0].live_ip(), Some("
|
|
599
|
+
assert_eq!(rows[0].live_ip(), Some("192.0.2.101"));
|
|
600
600
|
}
|
|
601
601
|
|
|
602
602
|
#[test]
|
|
603
603
|
fn to_json_emits_exactly_the_published_field_set() {
|
|
604
604
|
let rows = merge_rows(
|
|
605
|
-
vec![make_client("A", "aa:bb:cc:dd:ee:01", "
|
|
605
|
+
vec![make_client("A", "aa:bb:cc:dd:ee:01", "192.0.2.1", "WIRED")],
|
|
606
606
|
vec![],
|
|
607
607
|
);
|
|
608
608
|
let json = rows[0].to_json();
|
|
@@ -647,8 +647,8 @@ mod tests {
|
|
|
647
647
|
#[test]
|
|
648
648
|
fn filter_no_constraints() {
|
|
649
649
|
let clients = vec![
|
|
650
|
-
make_client("A", "aa:bb:cc:dd:ee:ff", "
|
|
651
|
-
make_client("B", "11:22:33:44:55:66", "
|
|
650
|
+
make_client("A", "aa:bb:cc:dd:ee:ff", "192.0.2.1", "WIRED"),
|
|
651
|
+
make_client("B", "11:22:33:44:55:66", "192.0.2.2", "WIRELESS"),
|
|
652
652
|
];
|
|
653
653
|
let filter = ListFilter {
|
|
654
654
|
wired: false,
|
|
@@ -661,8 +661,8 @@ mod tests {
|
|
|
661
661
|
#[test]
|
|
662
662
|
fn filter_wired_only() {
|
|
663
663
|
let clients = vec![
|
|
664
|
-
make_client("A", "aa:bb:cc:dd:ee:ff", "
|
|
665
|
-
make_client("B", "11:22:33:44:55:66", "
|
|
664
|
+
make_client("A", "aa:bb:cc:dd:ee:ff", "192.0.2.1", "WIRED"),
|
|
665
|
+
make_client("B", "11:22:33:44:55:66", "192.0.2.2", "WIRELESS"),
|
|
666
666
|
];
|
|
667
667
|
let filter = ListFilter {
|
|
668
668
|
wired: true,
|
|
@@ -677,8 +677,8 @@ mod tests {
|
|
|
677
677
|
#[test]
|
|
678
678
|
fn filter_wireless_only() {
|
|
679
679
|
let clients = vec![
|
|
680
|
-
make_client("A", "aa:bb:cc:dd:ee:ff", "
|
|
681
|
-
make_client("B", "11:22:33:44:55:66", "
|
|
680
|
+
make_client("A", "aa:bb:cc:dd:ee:ff", "192.0.2.1", "WIRED"),
|
|
681
|
+
make_client("B", "11:22:33:44:55:66", "192.0.2.2", "WIRELESS"),
|
|
682
682
|
];
|
|
683
683
|
let filter = ListFilter {
|
|
684
684
|
wired: false,
|
|
@@ -693,8 +693,8 @@ mod tests {
|
|
|
693
693
|
#[test]
|
|
694
694
|
fn filter_by_name_case_insensitive() {
|
|
695
695
|
let clients = vec![
|
|
696
|
-
make_client("Mac Mini", "aa:bb:cc:dd:ee:ff", "
|
|
697
|
-
make_client("iPhone", "11:22:33:44:55:66", "
|
|
696
|
+
make_client("Mac Mini", "aa:bb:cc:dd:ee:ff", "192.0.2.1", "WIRED"),
|
|
697
|
+
make_client("iPhone", "11:22:33:44:55:66", "192.0.2.2", "WIRELESS"),
|
|
698
698
|
];
|
|
699
699
|
let filter = ListFilter {
|
|
700
700
|
wired: false,
|
|
@@ -708,7 +708,7 @@ mod tests {
|
|
|
708
708
|
|
|
709
709
|
#[test]
|
|
710
710
|
fn filter_by_name_no_match() {
|
|
711
|
-
let clients = vec![make_client("A", "aa:bb:cc:dd:ee:ff", "
|
|
711
|
+
let clients = vec![make_client("A", "aa:bb:cc:dd:ee:ff", "192.0.2.1", "WIRED")];
|
|
712
712
|
let filter = ListFilter {
|
|
713
713
|
wired: false,
|
|
714
714
|
wireless: false,
|
|
@@ -720,9 +720,9 @@ mod tests {
|
|
|
720
720
|
#[test]
|
|
721
721
|
fn filter_combined_type_and_name() {
|
|
722
722
|
let clients = vec![
|
|
723
|
-
make_client("Mac Mini", "aa:bb:cc:dd:ee:ff", "
|
|
724
|
-
make_client("MacBook", "11:22:33:44:55:66", "
|
|
725
|
-
make_client("iPhone", "22:33:44:55:66:77", "
|
|
723
|
+
make_client("Mac Mini", "aa:bb:cc:dd:ee:ff", "192.0.2.1", "WIRED"),
|
|
724
|
+
make_client("MacBook", "11:22:33:44:55:66", "192.0.2.2", "WIRELESS"),
|
|
725
|
+
make_client("iPhone", "22:33:44:55:66:77", "192.0.2.3", "WIRELESS"),
|
|
726
726
|
];
|
|
727
727
|
let filter = ListFilter {
|
|
728
728
|
wired: false,
|