unifi-cli 0.3.3__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.3 → unifi_cli-0.3.4}/CHANGELOG.md +25 -0
  2. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/Cargo.lock +2 -1
  3. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/Cargo.toml +4 -1
  4. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/PKG-INFO +2 -2
  5. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/README.md +1 -1
  6. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/client.rs +67 -15
  7. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/mod.rs +2 -2
  8. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/tests.rs +8 -4
  9. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/types.rs +64 -31
  10. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/clients.rs +38 -9
  11. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/devices.rs +1 -0
  12. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/protect.rs +101 -63
  13. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/system.rs +20 -6
  14. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/main.rs +24 -1
  15. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/output.rs +48 -4
  16. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/schema.rs +17 -4
  17. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/tui.rs +1302 -245
  18. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/cli_contract.rs +47 -0
  19. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/mock_server.rs +1308 -28
  20. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/.github/workflows/ci.yml +0 -0
  21. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/.github/workflows/release.yml +0 -0
  22. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/.gitignore +0 -0
  23. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/LICENSE +0 -0
  24. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/Makefile +0 -0
  25. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/prek.toml +0 -0
  26. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/pyproject.toml +0 -0
  27. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/events.rs +0 -0
  28. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/mod.rs +0 -0
  29. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/networks.rs +0 -0
  30. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/ports.rs +0 -0
  31. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/fields.rs +0 -0
  32. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/lib.rs +0 -0
  33. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/fixtures/clispec-v0.2.json +0 -0
  34. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/no_real_network_data.rs +0 -0
  35. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/spec_compliance.rs +0 -0
  36. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/unifi_cli/__init__.py +0 -0
  37. {unifi_cli-0.3.3 → unifi_cli-0.3.4}/unifi_cli/__main__.py +0 -0
@@ -2,6 +2,31 @@
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
+
5
30
  ## [0.3.3](https://github.com/rvben/unifi-cli/compare/v0.3.2...v0.3.3) - 2026-08-10
6
31
 
7
32
  ### Fixed
@@ -2786,13 +2786,14 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2786
2786
 
2787
2787
  [[package]]
2788
2788
  name = "unifi-cli"
2789
- version = "0.3.3"
2789
+ version = "0.3.4"
2790
2790
  dependencies = [
2791
2791
  "clap",
2792
2792
  "clap_complete",
2793
2793
  "crossterm",
2794
2794
  "dirs",
2795
2795
  "jsonschema",
2796
+ "libc",
2796
2797
  "owo-colors",
2797
2798
  "ratatui",
2798
2799
  "reqwest",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "unifi-cli"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for UniFi Network controller"
@@ -33,6 +33,9 @@ ratatui = "0.30"
33
33
  crossterm = "0.29"
34
34
  rpassword = "7.4.0"
35
35
 
36
+ [target.'cfg(unix)'.dependencies]
37
+ libc = "0.2"
38
+
36
39
  [dev-dependencies]
37
40
  tempfile = "3"
38
41
  wiremock = "0.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-cli
3
- Version: 0.3.3
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,7 +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
+ | 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 |
361
361
  | 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
362
362
  | 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
363
363
  | 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
@@ -336,7 +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
+ | 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 |
340
340
  | 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
341
341
  | 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
342
342
  | 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
@@ -11,6 +11,29 @@ 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
+
14
37
  /// Decode a successful response as JSON, naming what answered when it is not.
15
38
  ///
16
39
  /// UniFi OS answers a request for an application the controller does not have
@@ -48,7 +71,7 @@ async fn json_or_unsupported<T: DeserializeOwned>(
48
71
  };
49
72
  Err(ApiError::Unsupported {
50
73
  endpoint: endpoint.to_string(),
51
- content_type,
74
+ reason: UnsupportedReason::NotJson { content_type },
52
75
  })
53
76
  }
54
77
  }
@@ -430,21 +453,30 @@ impl UnifiClient {
430
453
  // `Event` shape, so fall back to it. (The full live event stream on newer
431
454
  // controllers is only exposed over the events WebSocket, which this REST
432
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.
433
461
  pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
434
- match self
435
- .get_legacy::<Event>(&format!("/stat/event?_limit={limit}"))
436
- .await
437
- {
462
+ let events_path = format!("/stat/event?_limit={limit}");
463
+ match self.get_legacy::<Event>(&events_path).await {
438
464
  Ok(events) => Ok(events),
439
- Err(ApiError::NotFound(_)) => {
440
- let mut alarms: Vec<Event> = self.get_legacy("/rest/alarm").await?;
465
+ Err(ApiError::NotFound(_)) => match self.get_legacy::<Event>("/rest/alarm").await {
441
466
  // `rest/alarm` is neither time-ordered nor limited server-side;
442
467
  // present the most recent `limit` records to match the
443
468
  // semantics `stat/event?_limit=` provided on older controllers.
444
- alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
445
- alarms.truncate(limit);
446
- Ok(alarms)
447
- }
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
+ },
448
480
  Err(e) => Err(e),
449
481
  }
450
482
  }
@@ -551,6 +583,11 @@ impl UnifiClient {
551
583
  /// Resolve a camera identifier (ID or name) to a camera ID.
552
584
  /// If the input is a 24-char hex string, treats it as an ID.
553
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.
554
591
  pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
555
592
  // If it looks like a Protect camera ID (24 hex chars), use it directly
556
593
  if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
@@ -559,15 +596,30 @@ impl UnifiClient {
559
596
  // Otherwise, search by name
560
597
  let cameras = self.list_protect_cameras().await?;
561
598
  let needle = id_or_name.to_lowercase();
562
- cameras
599
+ let mut matches: Vec<ProtectCamera> = cameras
563
600
  .into_iter()
564
- .find(|c| {
601
+ .filter(|c| {
565
602
  c.name
566
603
  .as_deref()
567
604
  .is_some_and(|n| n.trim().to_lowercase() == needle)
568
605
  })
569
- .map(|c| c.id)
570
- .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
+ }
571
623
  }
572
624
 
573
625
  // System
@@ -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
  };
@@ -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 ---
@@ -196,8 +196,15 @@ pub struct HostSystem {
196
196
  }
197
197
 
198
198
  impl HostSystem {
199
- pub fn update_available(&self) -> bool {
200
- self.device_state.as_deref() == Some("updateAvailable")
199
+ /// Whether a firmware update is waiting, or `None` when the host did not say.
200
+ ///
201
+ /// A host that reported no device state has not reported an up-to-date one,
202
+ /// so the answer is unknown rather than negative. Only a state the host did
203
+ /// report settles the question, either way.
204
+ pub fn update_available(&self) -> Option<bool> {
205
+ self.device_state
206
+ .as_deref()
207
+ .map(|state| state == "updateAvailable")
201
208
  }
202
209
  }
203
210
 
@@ -384,8 +391,7 @@ pub struct ProtectCamera {
384
391
  pub mac: Option<String>,
385
392
  pub state: Option<String>,
386
393
  pub model_key: Option<String>,
387
- #[serde(default)]
388
- pub is_mic_enabled: bool,
394
+ pub is_mic_enabled: Option<bool>,
389
395
  pub video_mode: Option<String>,
390
396
  pub feature_flags: Option<ProtectFeatureFlags>,
391
397
  }
@@ -425,19 +431,17 @@ pub struct ProtectCameraFull {
425
431
  pub uptime: Option<u64>,
426
432
  pub up_since: Option<u64>,
427
433
  pub last_seen: Option<u64>,
428
- #[serde(default)]
429
- pub is_recording: bool,
430
- #[serde(default)]
431
- pub is_motion_detected: bool,
432
- #[serde(default)]
433
- pub is_dark: bool,
434
+ pub is_recording: Option<bool>,
435
+ /// Tri-state for the same reason as `is_recording`: a camera that did not
436
+ /// report motion has not reported stillness.
437
+ pub is_motion_detected: Option<bool>,
438
+ pub is_dark: Option<bool>,
434
439
  pub video_codec: Option<String>,
435
440
  pub current_resolution: Option<String>,
436
441
  pub video_mode: Option<String>,
437
442
  pub hdr_type: Option<String>,
438
443
  pub phy_rate: Option<f64>,
439
- #[serde(default)]
440
- pub is_mic_enabled: bool,
444
+ pub is_mic_enabled: Option<bool>,
441
445
  #[serde(default)]
442
446
  pub is_poor_network: bool,
443
447
  pub last_motion: Option<u64>,
@@ -457,14 +461,14 @@ pub struct ProtectCameraFull {
457
461
  pub struct CameraChannel {
458
462
  pub id: u32,
459
463
  pub name: Option<String>,
460
- #[serde(default)]
461
- pub enabled: bool,
464
+ /// Tri-state like `is_rtsp_enabled` below: a channel whose state the camera
465
+ /// did not report is not a channel reported as switched off.
466
+ pub enabled: Option<bool>,
462
467
  pub width: Option<u32>,
463
468
  pub height: Option<u32>,
464
469
  pub fps: Option<u32>,
465
470
  pub bitrate: Option<u64>,
466
- #[serde(default)]
467
- pub is_rtsp_enabled: bool,
471
+ pub is_rtsp_enabled: Option<bool>,
468
472
  pub rtsp_alias: Option<String>,
469
473
  }
470
474
 
@@ -507,8 +511,9 @@ pub struct WifiConnectionState {
507
511
  #[serde(rename_all = "camelCase")]
508
512
  pub struct RecordingSettings {
509
513
  pub mode: Option<String>,
510
- #[serde(default)]
511
- pub enable_motion_detection: bool,
514
+ /// Tri-state: settings that did not mention motion detection have not said
515
+ /// it is switched off.
516
+ pub enable_motion_detection: Option<bool>,
512
517
  }
513
518
 
514
519
  /// RTSPS stream URLs keyed by quality level
@@ -528,18 +533,35 @@ pub enum ApiError {
528
533
  /// rejected locally before any HTTP call. Published by `unifi schema`
529
534
  /// as kind `conflict`, exit code 6.
530
535
  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
536
+ /// The controller does not serve this endpoint at all. Distinct from
534
537
  /// `NotFound` because the whole API is absent, not one record, so there is
535
538
  /// no other identifier worth trying. Published as kind `unsupported`.
536
539
  Unsupported {
537
540
  endpoint: String,
538
- content_type: String,
541
+ reason: UnsupportedReason,
539
542
  },
540
543
  Other(String),
541
544
  }
542
545
 
546
+ /// How the controller revealed that an endpoint is absent.
547
+ ///
548
+ /// Both forms mean the same thing to a caller, so they share one error kind.
549
+ /// They are kept apart because the message has to say what actually happened:
550
+ /// guessing at the wrong one sends the reader looking for a fault that is not
551
+ /// there.
552
+ #[derive(Debug)]
553
+ pub enum UnsupportedReason {
554
+ /// The endpoint answered with something other than JSON. UniFi OS proxies
555
+ /// a request for an application it does not have to its own web UI, so the
556
+ /// call returns 200 with an HTML page.
557
+ NotJson { content_type: String },
558
+ /// The controller rejected the endpoint itself rather than the request.
559
+ /// UniFi Network answers an unknown legacy resource this way, so a
560
+ /// firmware that has dropped an endpoint is indistinguishable from one
561
+ /// that never had it, and neither is worth retrying.
562
+ Removed,
563
+ }
564
+
543
565
  /// Scan a single error string for TLS certificate failure markers. rustls
544
566
  /// reports these as "invalid peer certificate: <reason>", so "certificate" is
545
567
  /// the reliable marker; "self-signed" is matched defensively.
@@ -620,21 +642,32 @@ impl fmt::Display for ApiError {
620
642
  )
621
643
  }
622
644
  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
- )?;
645
+ ApiError::Unsupported { endpoint, reason } => {
646
+ match reason {
647
+ UnsupportedReason::NotJson { content_type } => write!(
648
+ f,
649
+ "This controller does not serve {endpoint}: it answered with \
650
+ {content_type} instead of JSON"
651
+ )?,
652
+ UnsupportedReason::Removed => write!(
653
+ f,
654
+ "This controller does not serve {endpoint}: it rejected the endpoint \
655
+ itself, so no parameter or identifier would change the result"
656
+ )?,
657
+ }
632
658
  if endpoint.contains("/protect/") {
633
659
  write!(
634
660
  f,
635
661
  "\n Hint: UniFi OS proxies the request to its web UI when the Protect \
636
662
  application is not installed on the controller"
637
663
  )?;
664
+ } else if endpoint.contains("/stat/event") {
665
+ write!(
666
+ f,
667
+ "\n Hint: UniFi Network 9 removed the REST event log, and this \
668
+ controller does not serve /rest/alarm either. The remaining event \
669
+ stream is the events WebSocket, which this CLI does not consume"
670
+ )?;
638
671
  }
639
672
  Ok(())
640
673
  }
@@ -387,8 +387,13 @@ pub async fn kick(
387
387
  Ok(())
388
388
  }
389
389
 
390
- pub(crate) fn total_bytes(c: &LegacyClient) -> u64 {
391
- c.tx_bytes.unwrap_or(0) + c.rx_bytes.unwrap_or(0)
390
+ /// Total traffic, or `None` when the controller did not report the counters.
391
+ ///
392
+ /// A counter the controller did not report is not a counter of zero: a client
393
+ /// whose traffic is unknown is not an idle client. Both halves are needed for a
394
+ /// total, since one alone is only part of the number the column claims to show.
395
+ pub(crate) fn total_bytes(c: &LegacyClient) -> Option<u64> {
396
+ c.tx_bytes.zip(c.rx_bytes).map(|(tx, rx)| tx + rx)
392
397
  }
393
398
 
394
399
  pub async fn top(
@@ -397,7 +402,13 @@ pub async fn top(
397
402
  limit: usize,
398
403
  ) -> Result<(), Box<dyn std::error::Error>> {
399
404
  let mut clients = client.list_clients_legacy().await?;
400
- clients.sort_by_key(|c| std::cmp::Reverse(total_bytes(c)));
405
+ // A client the controller published no counters for cannot be placed in a
406
+ // ranking by traffic, so it sits after everything that can, rather than
407
+ // being ranked as though it had transferred nothing.
408
+ clients.sort_by_key(|c| {
409
+ let total = total_bytes(c);
410
+ (total.is_none(), std::cmp::Reverse(total.unwrap_or(0)))
411
+ });
401
412
  let top_clients: Vec<&LegacyClient> = clients.iter().take(limit).collect();
402
413
 
403
414
  if out.is_json() {
@@ -443,9 +454,10 @@ pub async fn top(
443
454
  .map(|m| format_mac(&normalize_mac(m)))
444
455
  .unwrap_or_else(|| "-".into());
445
456
  let ip = c.ip.as_deref().unwrap_or("-");
446
- let tx = format_bytes(c.tx_bytes.unwrap_or(0));
447
- let rx = format_bytes(c.rx_bytes.unwrap_or(0));
448
- let total = format_bytes(total_bytes(c));
457
+ let bytes = |v: Option<u64>| v.map(format_bytes).unwrap_or_else(|| "-".into());
458
+ let tx = bytes(c.tx_bytes);
459
+ let rx = bytes(c.rx_bytes);
460
+ let total = bytes(total_bytes(c));
449
461
  let pad = name_w - 1;
450
462
 
451
463
  if color {
@@ -627,19 +639,36 @@ mod tests {
627
639
  fn total_bytes_both_present() {
628
640
  let c: LegacyClient =
629
641
  serde_json::from_str(r#"{"_id": "x", "tx_bytes": 100, "rx_bytes": 200}"#).unwrap();
630
- assert_eq!(total_bytes(&c), 300);
642
+ assert_eq!(total_bytes(&c), Some(300));
643
+ }
644
+
645
+ /// A client that really did transfer nothing is a measurement, and stays
646
+ /// distinguishable from one the controller said nothing about.
647
+ #[test]
648
+ fn total_bytes_measured_zero() {
649
+ let c: LegacyClient =
650
+ serde_json::from_str(r#"{"_id": "x", "tx_bytes": 0, "rx_bytes": 0}"#).unwrap();
651
+ assert_eq!(total_bytes(&c), Some(0));
631
652
  }
632
653
 
633
654
  #[test]
634
655
  fn total_bytes_none_values() {
635
656
  let c: LegacyClient = serde_json::from_str(r#"{"_id": "x"}"#).unwrap();
636
- assert_eq!(total_bytes(&c), 0);
657
+ assert_eq!(
658
+ total_bytes(&c),
659
+ None,
660
+ "counters the controller never sent are unknown, not zero"
661
+ );
637
662
  }
638
663
 
639
664
  #[test]
640
665
  fn total_bytes_partial() {
641
666
  let c: LegacyClient = serde_json::from_str(r#"{"_id": "x", "tx_bytes": 500}"#).unwrap();
642
- assert_eq!(total_bytes(&c), 500);
667
+ assert_eq!(
668
+ total_bytes(&c),
669
+ None,
670
+ "half a total is not a total: 500 would claim rx was measured at zero"
671
+ );
643
672
  }
644
673
 
645
674
  // --- apply_filter ---
@@ -185,6 +185,7 @@ pub async fn show(
185
185
  "mac": d.mac,
186
186
  "ip": d.ip,
187
187
  "state": d.state_str(),
188
+ "firmware": d.version,
188
189
  "version": d.version,
189
190
  "uptime": d.uptime,
190
191
  "num_sta": d.num_sta,