Skip to main content

garmin_cli/cli/commands/
devices.rs

1//! Device commands for garmin-cli
2
3use duckdb::Connection;
4
5use crate::client::GarminClient;
6use crate::config::CredentialStore;
7use crate::error::Result;
8use crate::storage::default_storage_path;
9
10use super::auth::refresh_token;
11
12/// List registered devices
13pub async fn list(profile: Option<String>) -> Result<()> {
14    let store = CredentialStore::new(profile)?;
15    let (oauth1, oauth2) = refresh_token(&store).await?;
16
17    let client = GarminClient::new(&oauth1.domain);
18
19    let path = "/device-service/deviceregistration/devices";
20
21    let devices: Vec<serde_json::Value> = client.get_json(&oauth2, path).await?;
22
23    if devices.is_empty() {
24        println!("No devices found.");
25        return Ok(());
26    }
27
28    println!(
29        "{:<20} {:<25} {:<15} {:<12}",
30        "Device", "Model", "Software", "Last Sync"
31    );
32    println!("{}", "-".repeat(75));
33
34    for device in &devices {
35        let name = device
36            .get("displayName")
37            .or_else(|| device.get("deviceTypeName"))
38            .and_then(|v| v.as_str())
39            .unwrap_or("Unknown");
40
41        let model = device
42            .get("partNumber")
43            .and_then(|v| v.as_str())
44            .unwrap_or("-");
45
46        let software = device
47            .get("currentFirmwareVersion")
48            .and_then(|v| v.as_str())
49            .unwrap_or("-");
50
51        let last_sync = device
52            .get("lastSyncTime")
53            .and_then(|v| v.as_str())
54            .map(|s| s.split('T').next().unwrap_or(s))
55            .unwrap_or("-");
56
57        println!(
58            "{:<20} {:<25} {:<15} {:<12}",
59            truncate(name, 19),
60            truncate(model, 24),
61            truncate(software, 14),
62            last_sync
63        );
64    }
65
66    println!("\nTotal: {} device(s)", devices.len());
67
68    Ok(())
69}
70
71/// Get device info
72pub async fn get(device_id: &str, profile: Option<String>) -> Result<()> {
73    let store = CredentialStore::new(profile)?;
74    let (oauth1, oauth2) = refresh_token(&store).await?;
75
76    let client = GarminClient::new(&oauth1.domain);
77
78    let path = format!(
79        "/device-service/deviceservice/device-info/settings/{}",
80        device_id
81    );
82
83    let data: serde_json::Value = client.get_json(&oauth2, &path).await?;
84
85    println!("{}", serde_json::to_string_pretty(&data)?);
86
87    Ok(())
88}
89
90fn truncate(s: &str, max_len: usize) -> String {
91    if s.len() <= max_len {
92        s.to_string()
93    } else {
94        format!("{}...", &s[..max_len.saturating_sub(3)])
95    }
96}
97
98/// Show device history from synced activities
99pub async fn history(storage_path: Option<String>) -> Result<()> {
100    let storage_path = storage_path
101        .map(std::path::PathBuf::from)
102        .unwrap_or_else(default_storage_path);
103
104    let activities_path = storage_path.join("activities");
105    if !activities_path.exists() {
106        println!("No activities found at: {}", activities_path.display());
107        println!("Run 'garmin sync run' to sync your activities first.");
108        return Ok(());
109    }
110
111    // Use DuckDB to query Parquet files with glob pattern
112    let conn =
113        Connection::open_in_memory().map_err(|e| crate::GarminError::Database(e.to_string()))?;
114
115    let glob_pattern = format!("{}/*.parquet", activities_path.display());
116
117    // Query unique devices from activity metadata
118    let mut stmt = conn
119        .prepare(&format!(
120            "SELECT
121                json_extract_string(raw_json, '$.metadataDTO.deviceMetaDataDTO.deviceId') as device_id,
122                json_extract(raw_json, '$.metadataDTO.deviceMetaDataDTO.deviceTypePk') as device_type,
123                MIN(start_time_local) as first_activity,
124                MAX(start_time_local) as last_activity,
125                COUNT(*) as activity_count
126            FROM '{}'
127            WHERE raw_json IS NOT NULL
128              AND json_extract_string(raw_json, '$.metadataDTO.deviceMetaDataDTO.deviceId') IS NOT NULL
129            GROUP BY 1, 2
130            ORDER BY first_activity",
131            glob_pattern
132        ))
133        .map_err(|e| crate::GarminError::Database(e.to_string()))?;
134
135    #[allow(clippy::type_complexity)]
136    let devices: Vec<(
137        Option<String>,
138        Option<i64>,
139        Option<String>,
140        Option<String>,
141        i64,
142    )> = stmt
143        .query_map([], |row| {
144            Ok((
145                row.get::<_, Option<String>>(0)?,
146                row.get::<_, Option<i64>>(1)?,
147                row.get::<_, Option<String>>(2)?,
148                row.get::<_, Option<String>>(3)?,
149                row.get::<_, i64>(4)?,
150            ))
151        })
152        .map_err(|e| crate::GarminError::Database(e.to_string()))?
153        .filter_map(|r| r.ok())
154        .collect();
155
156    if devices.is_empty() {
157        println!("No device history found in synced activities.");
158        println!("Make sure you have synced activities with 'garmin sync run'.");
159        return Ok(());
160    }
161
162    println!("Device history (from synced activities):\n");
163    println!(
164        "{:<15} {:<12} {:<12} {:<12} {:>10}",
165        "Device ID", "Type PK", "First Used", "Last Used", "Activities"
166    );
167    println!("{}", "-".repeat(65));
168
169    for (device_id, device_type, first, last, count) in &devices {
170        let device_id_str = device_id.as_deref().unwrap_or("-");
171        let device_type_str = device_type
172            .map(|t| t.to_string())
173            .unwrap_or_else(|| "-".to_string());
174        let first_date = first
175            .as_ref()
176            .and_then(|s| s.split(' ').next())
177            .unwrap_or("-");
178        let last_date = last
179            .as_ref()
180            .and_then(|s| s.split(' ').next())
181            .unwrap_or("-");
182
183        println!(
184            "{:<15} {:<12} {:<12} {:<12} {:>10}",
185            truncate(device_id_str, 14),
186            truncate(&device_type_str, 11),
187            first_date,
188            last_date,
189            count
190        );
191    }
192
193    println!("\nTotal: {} device(s) found", devices.len());
194
195    // Show a note about device type lookup
196    println!("\nNote: Device Type PK can be looked up in Garmin's device database.");
197    println!("Common types: 37010=Forerunner 955, 30380=Edge 810, etc.");
198
199    Ok(())
200}