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.
Files changed (37) hide show
  1. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/CHANGELOG.md +8 -1
  2. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/Cargo.lock +1 -1
  3. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/Cargo.toml +1 -1
  4. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/PKG-INFO +2 -1
  5. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/README.md +1 -0
  6. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/client.rs +63 -22
  7. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/tests.rs +28 -28
  8. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/types.rs +27 -0
  9. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/clients.rs +34 -34
  10. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/ports.rs +6 -6
  11. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/main.rs +6 -6
  12. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/output.rs +52 -0
  13. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/schema.rs +6 -0
  14. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/tui.rs +21 -21
  15. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/mock_server.rs +327 -75
  16. unifi_cli-0.3.3/tests/no_real_network_data.rs +245 -0
  17. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/.github/workflows/ci.yml +0 -0
  18. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/.github/workflows/release.yml +0 -0
  19. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/.gitignore +0 -0
  20. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/LICENSE +0 -0
  21. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/Makefile +0 -0
  22. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/prek.toml +0 -0
  23. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/pyproject.toml +0 -0
  24. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/api/mod.rs +0 -0
  25. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/devices.rs +0 -0
  26. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/events.rs +0 -0
  27. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/mod.rs +0 -0
  28. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/networks.rs +0 -0
  29. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/protect.rs +0 -0
  30. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/commands/system.rs +0 -0
  31. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/fields.rs +0 -0
  32. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/src/lib.rs +0 -0
  33. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/cli_contract.rs +0 -0
  34. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/fixtures/clispec-v0.2.json +0 -0
  35. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/tests/spec_compliance.rs +0 -0
  36. {unifi_cli-0.3.2 → unifi_cli-0.3.3}/unifi_cli/__init__.py +0 -0
  37. {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., "garage-bluetooth-proxy" instead of "garage-bluetooth-proxy 43:3c")
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
@@ -2786,7 +2786,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2786
2786
 
2787
2787
  [[package]]
2788
2788
  name = "unifi-cli"
2789
- version = "0.3.2"
2789
+ version = "0.3.3"
2790
2790
  dependencies = [
2791
2791
  "clap",
2792
2792
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "unifi-cli"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for UniFi Network controller"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-cli
3
- Version: 0.3.2
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
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.json().await?;
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 url = format!(
150
- "{}/proxy/network/api/s/default/cmd/{manager}",
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
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
- Ok(resp.json().await?)
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 url = format!(
471
- "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream",
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
- Ok(resp.json().await?)
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
- Ok(resp.json().await?)
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 url = format!("{}/proxy/protect/api{path}", self.base_url);
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
- 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}")))
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": "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");
@@ -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("garage-bluetooth-proxy ee:ff", Some("aa:bb:cc:dd:ee:ff")),
678
- "garage-bluetooth-proxy"
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("garage-bluetooth-proxy eeff", Some("aa:bb:cc:dd:ee:ff")),
687
- "garage-bluetooth-proxy"
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
- "70:03:9f:90:3c:29",
499
- "10.10.20.202",
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":"70:03:9f:90:3c:29","ip":"10.10.20.202","essid":"Notwork",
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"], "Notwork");
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"], "10.10.20.202");
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", "10.0.0.1", "WIRED")];
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":"10.0.0.5"}"#,
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("10.0.0.5"));
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
- "c4:dd:57:1d:07:e6",
536
- "10.10.20.203",
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":"c4:dd:57:1d:07:e6","essid":"Network"}"#,
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
- "10.0.0.1",
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", "10.0.0.1", "WIRED"),
574
- make_client("B", "aa:bb:cc:dd:ee:02", "10.0.0.2", "WIRELESS"),
575
- make_client("C", "aa:bb:cc:dd:ee:03", "10.0.0.3", "WIRELESS"),
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":"1.2.3.4"}"#,
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("1.2.3.4"));
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", "10.0.0.1", "WIRED")];
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":"1.1.1.1"}"#),
595
- legacy(r#"{"_id":"2","mac":"ff:ff:ff:ff:ff:ff","ip":"2.2.2.2"}"#),
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("1.1.1.1"));
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", "10.0.0.1", "WIRED")],
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", "10.0.0.1", "WIRED"),
651
- make_client("B", "11:22:33:44:55:66", "10.0.0.2", "WIRELESS"),
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", "10.0.0.1", "WIRED"),
665
- make_client("B", "11:22:33:44:55:66", "10.0.0.2", "WIRELESS"),
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", "10.0.0.1", "WIRED"),
681
- make_client("B", "11:22:33:44:55:66", "10.0.0.2", "WIRELESS"),
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", "10.0.0.1", "WIRED"),
697
- make_client("iPhone", "11:22:33:44:55:66", "10.0.0.2", "WIRELESS"),
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", "10.0.0.1", "WIRED")];
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", "10.0.0.1", "WIRED"),
724
- make_client("MacBook", "11:22:33:44:55:66", "10.0.0.2", "WIRELESS"),
725
- make_client("iPhone", "22:33:44:55:66:77", "10.0.0.3", "WIRELESS"),
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,