polestar_api/
client.rs

1//! HTTP client for interacting with the Polestar API.
2
3use crate::auth::AuthState;
4use crate::error::{PolestarError, Result};
5use crate::graphql;
6use crate::models::{telemetry::Telemetry, vehicle::Vehicle};
7use std::sync::Arc;
8
9/// Main client for interacting with the Polestar API.
10///
11/// # Example
12///
13/// ```no_run
14/// use polestar_api::PolestarClient;
15///
16/// #[tokio::main]
17/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
18///     let client = PolestarClient::new("your_username", "your_password")?;
19///     let telemetry = client.get_telemetry("YOUR_VIN").await?;
20///     Ok(())
21/// }
22/// ```
23#[derive(Clone)]
24pub struct PolestarClient {
25    http_client: reqwest::Client,
26    auth_state: Arc<AuthState>,
27    pc_api_base: String,
28    cms_api_base: String,
29}
30
31impl PolestarClient {
32    /// Creates a new Polestar API client with the provided credentials.
33    ///
34    /// The client will use these credentials to authenticate with the Polestar API
35    /// via the web-based login flow and obtain access tokens as needed.
36    ///
37    /// # Arguments
38    ///
39    /// * `username` - Polestar account username (email)
40    /// * `password` - Polestar account password
41    ///
42    /// # Example
43    ///
44    /// ```no_run
45    /// # use polestar_api::PolestarClient;
46    /// let client = PolestarClient::new("user@example.com", "password").unwrap();
47    /// ```
48    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Result<Self> {
49        let http_client = reqwest::Client::builder()
50            .cookie_store(true)
51            .timeout(std::time::Duration::from_secs(30))
52            .build()?;
53
54        let auth_state = Arc::new(AuthState::new(username.into(), password.into()));
55
56        Ok(Self {
57            http_client,
58            auth_state,
59            pc_api_base: "https://pc-api.polestar.com/eu-north-1/mystar-v2".to_string(),
60            cms_api_base: "https://cms-api.polestar.com/".to_string(),
61        })
62    }
63
64    /// Fetches telemetry data for the specified VIN.
65    ///
66    /// # Arguments
67    ///
68    /// * `vin` - Vehicle Identification Number
69    ///
70    /// # Example
71    ///
72    /// ```no_run
73    /// # use polestar_api::PolestarClient;
74    /// # #[tokio::main]
75    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
76    /// # let client = PolestarClient::new("user@example.com", "password")?;
77    /// let telemetry = client.get_telemetry("VIN123").await?;
78    /// println!("Battery: {:?}%", telemetry.battery.charge_level_percentage);
79    /// # Ok(())
80    /// # }
81    /// ```
82    pub async fn get_telemetry(&self, vin: &str) -> Result<Telemetry> {
83        let variables = serde_json::json!({
84            "vin": vin
85        });
86
87        self.post_graphql(&self.pc_api_base, graphql::queries::CAR_TELEMETRICS_V2, variables)
88            .await
89    }
90
91    /// Fetches complete vehicle information for the specified VIN.
92    ///
93    /// # Arguments
94    ///
95    /// * `vin` - Vehicle Identification Number
96    pub async fn get_vehicle(&self, vin: &str) -> Result<Vehicle> {
97        let variables = serde_json::json!({});
98
99        // Get token
100        let token = self.authenticate().await?;
101
102        let body = serde_json::json!({
103            "query": graphql::queries::GET_CONSUMER_CARS_V2,
104            "variables": variables
105        });
106
107        let response = self
108            .http_client
109            .post(&self.pc_api_base)
110            .header("authorization", format!("Bearer {}", token))
111            .header("content-type", "application/json")
112            .header("origin", "https://www.polestar.com")
113            .json(&body)
114            .send()
115            .await?;
116
117        let json: serde_json::Value = response.json().await?;
118
119        // Check for GraphQL errors
120        if let Some(errors) = json.get("errors") {
121            if let Some(message) = errors.get(0).and_then(|e| e.get("message")) {
122                return Err(PolestarError::GraphQLError(message.to_string()));
123            }
124        }
125
126        // Extract data.getConsumerCarsV2 field
127        let vehicles_data = json
128            .get("data")
129            .and_then(|d| d.get("getConsumerCarsV2"))
130            .ok_or_else(|| PolestarError::ApiError("No getConsumerCarsV2 field in response".to_string()))?;
131
132        let vehicles: Vec<Vehicle> = serde_json::from_value(vehicles_data.clone())?;
133
134        // Find the vehicle with matching VIN
135        vehicles
136            .into_iter()
137            .find(|v| v.vin == vin)
138            .ok_or_else(|| PolestarError::InvalidVin(format!("VIN {} not found", vin)))
139    }
140
141    /// Authenticates with Polestar and returns an access token.
142    ///
143    /// This method implements the web-based login flow using the stored credentials.
144    /// The token is cached and reused for subsequent requests.
145    async fn authenticate(&self) -> Result<String> {
146        self.auth_state.get_valid_token(&self.http_client).await
147    }
148
149    /// Internal method to execute GraphQL queries.
150    async fn post_graphql<T>(
151        &self,
152        endpoint: &str,
153        query: &str,
154        variables: serde_json::Value,
155    ) -> Result<T>
156    where
157        T: serde::de::DeserializeOwned,
158    {
159        // Get valid token (will authenticate if needed)
160        let token = self.authenticate().await?;
161
162        let body = serde_json::json!({
163            "query": query,
164            "variables": variables
165        });
166
167        let response = self
168            .http_client
169            .post(endpoint)
170            .header("authorization", format!("Bearer {}", token))
171            .header("content-type", "application/json")
172            .header("origin", "https://www.polestar.com")
173            .json(&body)
174            .send()
175            .await?;
176
177        // Check status code
178        let status = response.status();
179        if status == reqwest::StatusCode::UNAUTHORIZED {
180            return Err(PolestarError::AuthError(
181                "Invalid credentials or expired session".to_string(),
182            ));
183        }
184        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
185            return Err(PolestarError::RateLimitExceeded);
186        }
187
188        // Parse response
189        let json: serde_json::Value = response.json().await?;
190
191        // Check for GraphQL errors
192        if let Some(errors) = json.get("errors") {
193            if let Some(message) = errors.get(0).and_then(|e| e.get("message")) {
194                return Err(PolestarError::GraphQLError(message.to_string()));
195            }
196        }
197
198        // Extract data field and deserialize
199        let data = json
200            .get("data")
201            .ok_or_else(|| PolestarError::ApiError("No data field in response".to_string()))?;
202
203        serde_json::from_value(data.clone()).map_err(|e| e.into())
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_client_creation() {
213        let client = PolestarClient::new("user@example.com", "password");
214        assert!(client.is_ok());
215    }
216}