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.
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/CHANGELOG.md +25 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/Cargo.lock +2 -1
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/Cargo.toml +4 -1
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/PKG-INFO +2 -2
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/README.md +1 -1
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/client.rs +67 -15
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/mod.rs +2 -2
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/tests.rs +8 -4
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/api/types.rs +64 -31
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/clients.rs +38 -9
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/devices.rs +1 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/protect.rs +101 -63
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/system.rs +20 -6
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/main.rs +24 -1
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/output.rs +48 -4
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/schema.rs +17 -4
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/tui.rs +1302 -245
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/cli_contract.rs +47 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/mock_server.rs +1308 -28
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/.github/workflows/release.yml +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/.gitignore +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/LICENSE +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/Makefile +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/prek.toml +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/pyproject.toml +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/events.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/mod.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/networks.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/commands/ports.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/fields.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/src/lib.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/fixtures/clispec-v0.2.json +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/no_real_network_data.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/tests/spec_compliance.rs +0 -0
- {unifi_cli-0.3.3 → unifi_cli-0.3.4}/unifi_cli/__init__.py +0 -0
- {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.
|
|
2789
|
+
version = "0.3.4"
|
|
2790
2790
|
dependencies = [
|
|
2791
2791
|
"clap",
|
|
2792
2792
|
"clap_complete",
|
|
2793
2793
|
"crossterm",
|
|
2794
2794
|
"dirs",
|
|
2795
2795
|
"jsonschema",
|
|
2796
|
+
"libc",
|
|
2796
2797
|
"owo-colors",
|
|
2797
2798
|
"ratatui",
|
|
2798
2799
|
"reqwest",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "unifi-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.90"
|
|
6
6
|
description = "CLI for UniFi Network controller"
|
|
@@ -33,6 +33,9 @@ ratatui = "0.30"
|
|
|
33
33
|
crossterm = "0.29"
|
|
34
34
|
rpassword = "7.4.0"
|
|
35
35
|
|
|
36
|
+
[target.'cfg(unix)'.dependencies]
|
|
37
|
+
libc = "0.2"
|
|
38
|
+
|
|
36
39
|
[dev-dependencies]
|
|
37
40
|
tempfile = "3"
|
|
38
41
|
wiremock = "0.6"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -357,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
|
|
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
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
570
|
-
|
|
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,
|
|
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
|
-
|
|
653
|
+
assert_eq!(host.update_available(), Some(true));
|
|
654
654
|
assert_eq!(host.name.as_deref(), Some("UCG Ultra"));
|
|
655
655
|
}
|
|
656
656
|
|
|
@@ -658,14 +658,18 @@ fn host_system_update_available() {
|
|
|
658
658
|
fn host_system_no_update() {
|
|
659
659
|
let json = r#"{"deviceState": "online"}"#;
|
|
660
660
|
let host: HostSystem = serde_json::from_str(json).unwrap();
|
|
661
|
-
|
|
661
|
+
assert_eq!(host.update_available(), Some(false));
|
|
662
662
|
}
|
|
663
663
|
|
|
664
664
|
#[test]
|
|
665
|
-
fn
|
|
665
|
+
fn host_system_missing_state_is_unknown_not_up_to_date() {
|
|
666
666
|
let json = r#"{}"#;
|
|
667
667
|
let host: HostSystem = serde_json::from_str(json).unwrap();
|
|
668
|
-
|
|
668
|
+
assert_eq!(
|
|
669
|
+
host.update_available(),
|
|
670
|
+
None,
|
|
671
|
+
"a host that reported no device state has not reported an up-to-date one"
|
|
672
|
+
);
|
|
669
673
|
}
|
|
670
674
|
|
|
671
675
|
// --- strip_mac_suffix ---
|
|
@@ -196,8 +196,15 @@ pub struct HostSystem {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
impl HostSystem {
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
pub is_motion_detected: bool
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
511
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
447
|
-
let
|
|
448
|
-
let
|
|
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!(
|
|
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!(
|
|
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 ---
|