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.
Files changed (37) hide show
  1. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/CHANGELOG.md +33 -1
  2. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/Cargo.lock +2 -1
  3. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/Cargo.toml +4 -1
  4. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/PKG-INFO +2 -1
  5. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/README.md +1 -0
  6. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/client.rs +129 -36
  7. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/mod.rs +2 -2
  8. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/tests.rs +36 -32
  9. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/api/types.rs +78 -18
  10. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/clients.rs +72 -43
  11. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/devices.rs +1 -0
  12. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/ports.rs +6 -6
  13. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/protect.rs +101 -63
  14. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/system.rs +20 -6
  15. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/main.rs +30 -7
  16. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/output.rs +97 -1
  17. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/schema.rs +22 -3
  18. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/tui.rs +1323 -266
  19. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/cli_contract.rs +47 -0
  20. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/mock_server.rs +1635 -103
  21. unifi_cli-0.3.4/tests/no_real_network_data.rs +245 -0
  22. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/.github/workflows/ci.yml +0 -0
  23. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/.github/workflows/release.yml +0 -0
  24. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/.gitignore +0 -0
  25. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/LICENSE +0 -0
  26. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/Makefile +0 -0
  27. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/prek.toml +0 -0
  28. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/pyproject.toml +0 -0
  29. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/events.rs +0 -0
  30. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/mod.rs +0 -0
  31. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/commands/networks.rs +0 -0
  32. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/fields.rs +0 -0
  33. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/src/lib.rs +0 -0
  34. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/fixtures/clispec-v0.2.json +0 -0
  35. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/tests/spec_compliance.rs +0 -0
  36. {unifi_cli-0.3.2 → unifi_cli-0.3.4}/unifi_cli/__init__.py +0 -0
  37. {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., "garage-bluetooth-proxy" instead of "garage-bluetooth-proxy 43:3c")
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.2"
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.2"
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.2
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
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.json().await?;
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 url = format!(
150
- "{}/proxy/network/api/s/default/cmd/{manager}",
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
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
- Ok(resp.json().await?)
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
- match self
391
- .get_legacy::<Event>(&format!("/stat/event?_limit={limit}"))
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
- alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
401
- alarms.truncate(limit);
402
- Ok(alarms)
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 url = format!(
471
- "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream",
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
- Ok(resp.json().await?)
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
- .find(|c| {
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
- .map(|c| c.id)
528
- .ok_or_else(|| ApiError::NotFound(format!("Camera '{id_or_name}'")))
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/protect/api{path}", self.base_url);
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
- let bytes = resp.bytes().await.map_err(ApiError::Http)?;
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, format_bytes,
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": "10.0.0.1", "name": "Test", "type": "WIRED"},
176
- {"macAddress": "11:22:33:44:55:66", "ipAddress": "10.0.0.2", "hostname": "host2", "type": "WIRELESS"}
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": "10.0.0.1", "is_wired": true}
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": "d0:11:e5:ce:d5:54",
241
- "ipAddress": "192.168.1.180",
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("d0:11:e5:ce:d5:54"));
248
- assert_eq!(client.ip_address.as_deref(), Some("192.168.1.180"));
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": "10.0.0.5",
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": "60:22:32:58:b8:00",
302
- "essid": "Notwork"
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("60:22:32:58:b8:00"));
313
- assert_eq!(client.ssid.as_deref(), Some("Notwork"));
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": "9c:05:d6:bc:06:43",
355
- "ipAddress": "192.168.1.1",
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("9c:05:d6:bc:06:43"));
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": "81.172.153.156",
410
- "isp_name": "Caiway NL"
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("81.172.153.156"));
416
- assert_eq!(health.isp_name.as_deref(), Some("Caiway NL"));
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[80:2a:a8:cd:47:ab] was connected",
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": "9c:05:d6:bc:06:43",
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": "9c:05:d6:bc:06:43",
585
- "ip": "192.168.1.1",
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("9c:05:d6:bc:06:43"));
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
- assert!(host.update_available());
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
- assert!(!host.update_available());
661
+ assert_eq!(host.update_available(), Some(false));
662
662
  }
663
663
 
664
664
  #[test]
665
- fn host_system_missing_state() {
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
- assert!(!host.update_available());
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("garage-bluetooth-proxy ee:ff", Some("aa:bb:cc:dd:ee:ff")),
678
- "garage-bluetooth-proxy"
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("garage-bluetooth-proxy eeff", Some("aa:bb:cc:dd:ee:ff")),
687
- "garage-bluetooth-proxy"
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