Skip to main content

enphase_api/client/
entrez.rs

1//! # Enphase Entrez Cloud Service Client
2//!
3//! This module provides a client for interacting with the Enphase Entrez
4//! service, which is used to manage authentication and generate JWT tokens for
5//! Envoy devices.
6//!
7//! The Entrez service is a cloud-based service that provides:
8//! - User authentication
9//! - JWT token generation for Envoy devices
10//! - Site and system information
11
12use crate::error::Result;
13use tracing::{debug, instrument};
14
15/// The default base URL for the Enphase Entrez service.
16const DEFAULT_ENTREZ_URL: &str = "https://entrez.enphaseenergy.com";
17
18/// Main client for the Enphase Entrez service.
19///
20/// This client provides authentication and token generation for accessing
21/// Envoy devices via JWT tokens.
22#[derive(Debug, Clone)]
23pub struct Entrez {
24    /// HTTP client for making requests.
25    client: reqwest::Client,
26    /// Base URL for the Entrez service.
27    base_url: String,
28}
29
30impl Default for Entrez {
31    /// Create a new Entrez client with the default URL.
32    ///
33    /// This connects to the official Enphase Entrez service at
34    /// `https://entrez.enphaseenergy.com`.
35    #[inline]
36    fn default() -> Self {
37        Self::new(DEFAULT_ENTREZ_URL)
38    }
39}
40
41impl Entrez {
42    /// Create a new Entrez client with the given URL.
43    ///
44    /// # Arguments
45    ///
46    /// * `url` - The base URL of the Entrez service
47    ///
48    /// # Returns
49    ///
50    /// Returns a new [`Entrez`] client configured for the given URL.
51    ///
52    /// # Example
53    ///
54    /// ```no_run
55    /// use enphase_api::Entrez;
56    ///
57    /// # #[tokio::main]
58    /// # async fn main() {
59    /// let client = Entrez::new("https://entrez.enphaseenergy.com");
60    /// # }
61    /// ```
62    #[inline]
63    #[expect(
64        clippy::missing_panics_doc,
65        clippy::expect_used,
66        reason = "reqwest::Client::builder() with basic config cannot fail"
67    )]
68    pub fn new(url: impl Into<String>) -> Self {
69        let base_url = url.into();
70
71        let client = reqwest::Client::builder()
72            .user_agent(format!("enphase-api/{}", env!("CARGO_PKG_VERSION")))
73            .cookie_store(true)
74            .timeout(core::time::Duration::from_secs(30))
75            .build()
76            .expect("Failed to build HTTP client");
77
78        Self { client, base_url }
79    }
80
81    /// Create a new Entrez client with the given URL and HTTP client.
82    ///
83    /// This allows you to provide a custom `reqwest::Client` with specific
84    /// configuration.
85    ///
86    /// # Arguments
87    ///
88    /// * `url` - The base URL of the Entrez service
89    /// * `client` - A configured `reqwest::Client`. The client should have
90    ///   cookie storage enabled to maintain session state.
91    ///
92    /// # Example
93    ///
94    /// ```no_run
95    /// use enphase_api::Entrez;
96    ///
97    /// # #[tokio::main]
98    /// # async fn main() {
99    /// let client = reqwest::Client::new();
100    /// let entrez = Entrez::with_client("https://entrez.enphaseenergy.com", client);
101    /// # }
102    /// ```
103    #[inline]
104    pub fn with_client(url: impl Into<String>, client: reqwest::Client) -> Self {
105        let base_url = url.into();
106
107        Self { client, base_url }
108    }
109
110    /// Log in to the Enphase Entrez service.
111    ///
112    /// This authenticates your account and maintains the session for subsequent
113    /// API calls. The session is maintained automatically by the HTTP agent.
114    ///
115    /// # Arguments
116    ///
117    /// * `username` - Your Enphase account username
118    /// * `password` - Your Enphase account password
119    ///
120    /// # Returns
121    ///
122    /// Returns `Ok(())` if login is successful.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the login fails due to invalid credentials or
127    /// network issues.
128    ///
129    /// # Example
130    ///
131    /// ```no_run
132    /// use enphase_api::Entrez;
133    ///
134    /// # #[tokio::main]
135    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
136    /// let client = Entrez::default();
137    /// client.login("user@example.com", "password").await?;
138    /// # Ok(())
139    /// # }
140    /// ```
141    #[inline]
142    #[instrument(skip(self, username, password), level = "debug")]
143    pub async fn login(&self, username: impl AsRef<str>, password: impl AsRef<str>) -> Result<()> {
144        let username_str = username.as_ref();
145        let password_str = password.as_ref();
146        debug!("Logging in to Enphase Entrez with {}", username_str);
147
148        let endpoint = format!("{}{}", self.base_url, "/login");
149        debug!("POST {endpoint}");
150
151        let form_data = [
152            ("username", username_str),
153            ("password", password_str),
154            ("authFlow", "entrezSession"),
155        ];
156
157        let response = self.client.post(&endpoint).form(&form_data).send().await?;
158        debug!("Status code: {}", response.status());
159
160        Ok(())
161    }
162
163    /// Log in to the Enphase Entrez service using environment variables.
164    ///
165    /// This authenticates your account using credentials from `ENTREZ_USERNAME`
166    /// and `ENTREZ_PASSWORD` environment variables. The session is maintained
167    /// automatically by the HTTP agent for subsequent API calls.
168    ///
169    /// # Returns
170    ///
171    /// Returns `Ok(())` if login is successful.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if:
176    /// - The `ENTREZ_USERNAME` or `ENTREZ_PASSWORD` environment variables are
177    ///   not set
178    /// - The login fails due to invalid credentials or network issues
179    ///
180    /// # Example
181    ///
182    /// ```no_run
183    /// use enphase_api::Entrez;
184    ///
185    /// # #[tokio::main]
186    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
187    /// // Set ENTREZ_USERNAME and ENTREZ_PASSWORD environment variables
188    /// let client = Entrez::default();
189    /// client.login_with_env().await?;
190    /// # Ok(())
191    /// # }
192    /// ```
193    #[inline]
194    pub async fn login_with_env(&self) -> Result<()> {
195        let username = std::env::var("ENTREZ_USERNAME").map_err(|_e| {
196            crate::error::EnphaseError::ConfigurationError(
197                "ENTREZ_USERNAME environment variable not set".to_owned(),
198            )
199        })?;
200
201        let password = std::env::var("ENTREZ_PASSWORD").map_err(|_e| {
202            crate::error::EnphaseError::ConfigurationError(
203                "ENTREZ_PASSWORD environment variable not set".to_owned(),
204            )
205        })?;
206
207        self.login(username, password).await
208    }
209
210    /// Generate a JWT token for accessing an Envoy device.
211    ///
212    /// This generates a token that can be used to authenticate with a specific
213    /// Envoy device. The token is typically valid for a limited time period.
214    ///
215    /// # Arguments
216    ///
217    /// * `site_name` - The name of the site
218    /// * `serial_number` - The serial number of the Envoy device
219    /// * `commissioned` - Whether the device is commissioned (`true`) or not
220    ///   (`false`)
221    ///
222    /// # Returns
223    ///
224    /// Returns the JWT token string on success.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if:
229    /// - The request fails
230    /// - The site or serial number is not found
231    /// - You are not logged in
232    ///
233    /// # Example
234    ///
235    /// ```no_run
236    /// use enphase_api::Entrez;
237    ///
238    /// # #[tokio::main]
239    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
240    /// let client = Entrez::default();
241    /// client.login("user@example.com", "password").await?;
242    ///
243    /// let token = client.generate_token("My Site", "121212121212", true).await?;
244    /// println!("Token: {}", token);
245    /// # Ok(())
246    /// # }
247    /// ```
248    #[inline]
249    #[instrument(skip(self, site_name, serial_number, commissioned), level = "debug")]
250    pub async fn generate_token(
251        &self,
252        site_name: impl AsRef<str>,
253        serial_number: impl AsRef<str>,
254        commissioned: bool,
255    ) -> Result<String> {
256        let site_name_str = site_name.as_ref();
257        let serial_number_str = serial_number.as_ref();
258        debug!(
259            "Generating token for site: {}, serial: {}",
260            site_name_str, serial_number_str
261        );
262
263        // Normalize site name: lowercase and replace spaces with +
264        let normalized_site = site_name_str.to_lowercase().replace(' ', "+");
265
266        let endpoint = format!("{}/entrez_tokens", self.base_url);
267        debug!("POST {endpoint}");
268
269        let form_data = [
270            ("uncommissioned", if commissioned { "on" } else { "off" }),
271            ("Site", normalized_site.as_str()),
272            ("serialNum", serial_number_str),
273        ];
274
275        let response = self.client.post(&endpoint).form(&form_data).send().await?;
276        debug!("Status code: {}", response.status());
277
278        // Read response as plain text to parse HTML
279        let response_text = response.text().await?;
280
281        // Parse the response HTML to extract the token
282        // Look for the textarea with id="JWTToken"
283        if let Some((_, rest)) = response_text.split_once(r#"id="JWTToken""#)
284            && let Some((_, start_textarea)) = rest.split_once('>')
285            && let Some((token_text, _)) = start_textarea.split_once("</textarea>")
286        {
287            let token = token_text.trim().to_owned();
288
289            if !token.is_empty() {
290                debug!("Token generated successfully");
291                return Ok(token);
292            }
293        }
294
295        Err(crate::error::EnphaseError::InvalidResponse(
296            "Failed to extract token from response".to_owned(),
297        ))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use pretty_assertions::assert_eq;
305    use wiremock::matchers::{body_string_contains, method, path};
306    use wiremock::{Mock, MockServer, ResponseTemplate};
307
308    // Helper to load fixture files
309    fn load_fixture(category: &str, name: &str) -> serde_json::Value {
310        let fixture_path = format!("fixtures/{category}/{name}.json");
311        let content = std::fs::read_to_string(&fixture_path)
312            .unwrap_or_else(|_| panic!("Failed to read fixture: {fixture_path}"));
313        serde_json::from_str(&content)
314            .unwrap_or_else(|_| panic!("Failed to parse fixture: {fixture_path}"))
315    }
316
317    #[tokio::test]
318    async fn login_success() {
319        let mock_server = MockServer::start().await;
320
321        let fixture = load_fixture("entrez", "login-success");
322        let status_code: u16 = fixture
323            .get("status_code")
324            .expect("status_code not found in fixture")
325            .as_u64()
326            .and_then(|v| v.try_into().ok())
327            .expect("status_code is not a valid u16");
328        // Find the set-cookie header in the headers array
329        let cookie_value = fixture
330            .get("headers")
331            .and_then(|h| h.as_array())
332            .and_then(|arr| {
333                arr.iter()
334                    .filter_map(|v| v.as_str())
335                    .find(|h| h.to_lowercase().starts_with("set-cookie:"))
336                    .map(|h| {
337                        h.trim_end_matches('\r')
338                            .trim_start_matches("set-cookie:")
339                            .trim()
340                    })
341            })
342            .expect("set-cookie header not found in fixture")
343            .to_owned();
344
345        Mock::given(method("POST"))
346            .and(path("/login"))
347            .and(body_string_contains("username=test%40example.com"))
348            .and(body_string_contains("password=test_password"))
349            .and(body_string_contains("authFlow=entrezSession"))
350            .respond_with(
351                ResponseTemplate::new(status_code).append_header("Set-Cookie", &cookie_value),
352            )
353            .expect(1)
354            .mount(&mock_server)
355            .await;
356
357        let client = Entrez::new(mock_server.uri());
358        let result = client.login("test@example.com", "test_password").await;
359
360        assert!(
361            result.is_ok(),
362            "Login should succeed with valid credentials"
363        );
364    }
365
366    #[tokio::test]
367    async fn login_invalid_credentials() {
368        let mock_server = MockServer::start().await;
369
370        let fixture = load_fixture("entrez", "login-failure");
371        let status_code: u16 = fixture
372            .get("status_code")
373            .and_then(serde_json::Value::as_u64)
374            .and_then(|v| v.try_into().ok())
375            .expect("status_code is not a valid u16");
376        let body = fixture
377            .get("body")
378            .and_then(serde_json::Value::as_str)
379            .expect("body is not a string")
380            .to_owned();
381
382        Mock::given(method("POST"))
383            .and(path("/login"))
384            .respond_with(ResponseTemplate::new(status_code).set_body_string(&body))
385            .mount(&mock_server)
386            .await;
387
388        let client = Entrez::new(mock_server.uri());
389        let result = client.login("wrong@example.com", "wrong_password").await;
390
391        // Note: Current implementation doesn't check status code in login()
392        // This test documents current behavior
393        assert!(
394            result.is_ok(),
395            "Current implementation accepts any response"
396        );
397    }
398
399    #[tokio::test]
400    async fn login_network_error() {
401        // Use an invalid URL to simulate network error
402        let client = Entrez::new("http://localhost:1");
403        let result = client.login("test@example.com", "test_password").await;
404
405        assert!(result.is_err(), "Login should fail with network error");
406        if let Err(err) = result {
407            assert!(
408                matches!(err, crate::error::EnphaseError::Http(_)),
409                "Error should be HTTP error type"
410            );
411        }
412    }
413
414    #[tokio::test]
415    async fn generate_token_success() {
416        let mock_server = MockServer::start().await;
417
418        let fixture = load_fixture("entrez", "generate-token-success");
419        let status_code: u16 = fixture
420            .get("status_code")
421            .and_then(serde_json::Value::as_u64)
422            .and_then(|v| v.try_into().ok())
423            .expect("status_code is not a valid u16");
424        let html_response = fixture
425            .get("body")
426            .and_then(serde_json::Value::as_str)
427            .expect("body is not a string")
428            .to_owned();
429
430        Mock::given(method("POST"))
431            .and(path("/entrez_tokens"))
432            .respond_with(
433                ResponseTemplate::new(status_code)
434                    .set_body_string(&html_response)
435                    .insert_header("Content-Type", "text/html; charset=utf-8"),
436            )
437            .mount(&mock_server)
438            .await;
439
440        let client = Entrez::new(mock_server.uri());
441        let token = client
442            .generate_token("My Site", "121212121212", true)
443            .await
444            .expect("Token generation should succeed");
445
446        // Token should not be empty
447        assert!(!token.is_empty(), "Token should not be empty");
448    }
449
450    #[tokio::test]
451    async fn generate_token_commissioned() {
452        let mock_server = MockServer::start().await;
453        let expected_token = "test_token_for_commissioned";
454
455        let html_response = format!(
456            r#"<html><body><textarea id="JWTToken">{expected_token}</textarea></body></html>"#
457        );
458
459        Mock::given(method("POST"))
460            .and(path("/entrez_tokens"))
461            .and(body_string_contains("uncommissioned=on"))
462            .respond_with(ResponseTemplate::new(200).set_body_string(html_response))
463            .mount(&mock_server)
464            .await;
465
466        let client = Entrez::new(mock_server.uri());
467        let token = client
468            .generate_token("Test Site", "603980032", true)
469            .await
470            .expect("Should succeed");
471
472        assert_eq!(token, expected_token);
473    }
474
475    #[tokio::test]
476    async fn generate_token_missing_textarea() {
477        let mock_server = MockServer::start().await;
478
479        // Use hardcoded response for this test - we want to test when textarea is completely missing
480        let html_response = "<!DOCTYPE html>
481<html>
482<head><title>Error</title></head>
483<body>
484    <p>Error: Invalid request or unauthorized</p>
485</body>
486</html>";
487
488        Mock::given(method("POST"))
489            .and(path("/entrez_tokens"))
490            .respond_with(ResponseTemplate::new(200).set_body_string(html_response))
491            .mount(&mock_server)
492            .await;
493
494        let client = Entrez::new(mock_server.uri());
495        let result = client.generate_token("My Site", "121212121212", true).await;
496
497        assert!(result.is_err(), "Should fail when token not in response");
498        if let Err(err) = result {
499            assert!(
500                matches!(err, crate::error::EnphaseError::InvalidResponse(_)),
501                "Error should be InvalidResponse type"
502            );
503        }
504    }
505
506    #[expect(
507        clippy::multiple_unsafe_ops_per_block,
508        reason = "Setting and removing environment variables in tests"
509    )]
510    #[tokio::test]
511    async fn login_with_env_success() {
512        let mock_server = MockServer::start().await;
513
514        Mock::given(method("POST"))
515            .and(path("/login"))
516            .respond_with(
517                ResponseTemplate::new(200).append_header("Set-Cookie", "sessionId=xyz789; Path=/"),
518            )
519            .mount(&mock_server)
520            .await;
521
522        // Set environment variables for test
523        // SAFETY: This is a test function and we need to set environment variables
524        // for testing purposes. The variables are cleaned up after the test.
525        unsafe {
526            std::env::set_var("ENTREZ_USERNAME", "env_test@example.com");
527            std::env::set_var("ENTREZ_PASSWORD", "env_test_password");
528        }
529
530        let client = Entrez::new(mock_server.uri());
531        let result = client.login_with_env().await;
532
533        // Clean up environment variables
534        // SAFETY: Removing test environment variables that were set earlier in this test.
535        // No other code should be accessing these variables concurrently.
536        unsafe {
537            std::env::remove_var("ENTREZ_USERNAME");
538            std::env::remove_var("ENTREZ_PASSWORD");
539        }
540
541        assert!(
542            result.is_ok(),
543            "Login with env vars should succeed when vars are set"
544        );
545    }
546}