Skip to main content

enphase_api/client/
envoy.rs

1//! # Enphase Envoy Local Gateway Client
2//!
3//! This module provides a client for interacting with the Enphase Envoy local gateway.
4//! The Envoy device is a gateway that provides local access to solar inverter data.
5//!
6//! ## Authentication
7//!
8//! The Envoy device typically uses JWT tokens for authentication. Tokens can be obtained
9//! from the Enphase Entrez service. Some Envoy models may require username/password
10//! authentication or digest authentication.
11//!
12//! ## Certificate Handling
13//!
14//! Envoy devices typically use self-signed certificates. This client is configured to
15//! accept invalid certificates by default.
16
17use core::fmt::Display;
18
19use crate::{
20    error::Result,
21    models::{PowerState, PowerStatusResponse},
22};
23use tracing::{debug, instrument};
24
25/// Main client for the Enphase Envoy local gateway.
26///
27/// This client provides access to local solar production, consumption, and inverter data.
28/// It handles session management and authentication with the Envoy device.
29#[derive(Debug, Clone)]
30pub struct Envoy {
31    /// HTTP client for making requests.
32    client: reqwest::Client,
33    /// Base URL for the Envoy gateway.
34    base_url: String,
35}
36
37impl Envoy {
38    /// Create a new Envoy client with the given host.
39    ///
40    /// The host can be a hostname (e.g., "envoy.local") or IP address (e.g.,
41    /// "192.168.1.100"). The client will connect via HTTPS by default.
42    ///
43    /// # Arguments
44    ///
45    /// * `host` - The hostname or IP address of the Envoy device
46    ///
47    /// # Returns
48    ///
49    /// Returns a new [`Envoy`] client configured for the given host.
50    ///
51    /// # Example
52    ///
53    /// ```no_run
54    /// use enphase_api::Envoy;
55    ///
56    /// let client = Envoy::new("envoy.local");
57    /// ```
58    #[inline]
59    #[expect(
60        clippy::missing_panics_doc,
61        clippy::expect_used,
62        reason = "reqwest::Client::builder() with basic config cannot fail"
63    )]
64    pub fn new(host: impl Display) -> Self {
65        let base_url = format!("https://{host}");
66
67        let client = reqwest::Client::builder()
68            .user_agent(format!("enphase-api/{}", env!("CARGO_PKG_VERSION")))
69            .cookie_store(true)
70            .timeout(core::time::Duration::from_secs(30))
71            .danger_accept_invalid_certs(true)
72            .build()
73            .expect("Failed to build HTTP client");
74
75        Self { client, base_url }
76    }
77
78    /// Create a new Envoy client with the given host and HTTP client.
79    ///
80    /// This allows you to provide a custom `reqwest::Client` with specific
81    /// configuration.
82    ///
83    /// Since the Envoy client uses self-signed certificates, ensure that the
84    /// provided client is configured to accept them if necessary (or ignore
85    /// certificate errors).
86    ///
87    /// Additionally, the Envoy client requires cookie storage to maintain
88    /// session state, so ensure that the provided client has cookie store
89    /// enabled.
90    ///
91    /// # Arguments
92    ///
93    /// * `host` - The hostname or IP address of the Envoy device
94    /// * `client` - A configured `reqwest::Client`
95    ///
96    /// # Example
97    ///
98    /// ```no_run
99    /// use enphase_api::Envoy;
100    ///
101    /// let client = reqwest::Client::new();
102    /// let envoy = Envoy::with_client("envoy.local", client);
103    /// ```
104    #[inline]
105    pub fn with_client(host: impl Display, client: reqwest::Client) -> Self {
106        let base_url = format!("https://{host}");
107
108        Self { client, base_url }
109    }
110
111    /// Authenticate with the Envoy device using a JWT token.
112    ///
113    /// This validates that the provided token is valid by checking it against
114    /// the Envoy device.
115    ///
116    /// # Arguments
117    ///
118    /// * `token` - The JWT token to authenticate with. This is typically
119    ///   obtained from the Enphase Entrez service.
120    ///
121    /// # Returns
122    ///
123    /// Returns `Ok(())` if authentication is successful.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the token is invalid or the authentication check fails.
128    ///
129    /// # Example
130    ///
131    /// ```no_run
132    /// use enphase_api::{Envoy, Entrez};
133    ///
134    /// # #[tokio::main]
135    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
136    /// let entrez = Entrez::default();
137    /// entrez.login_with_env().await?;
138    /// let token = entrez.generate_token("your-site-name", "your-envoy-serial-number", true).await?;
139    /// let client = Envoy::new("envoy.local");
140    /// client.authenticate(&token).await?;
141    /// # Ok(())
142    /// # }
143    /// ```
144    #[inline]
145    #[instrument(skip(self, token), level = "debug")]
146    pub async fn authenticate(&self, token: impl Display) -> Result<()> {
147        debug!("Authenticating Envoy via JWT");
148
149        let endpoint = format!("{}/auth/check_jwt", self.base_url);
150        debug!("GET {endpoint}");
151
152        let response = self
153            .client
154            .get(&endpoint)
155            .bearer_auth(token.to_string())
156            .send()
157            .await?;
158
159        let status = response.status();
160        debug!("Status code: {}", status);
161
162        let body = response.text().await?;
163
164        if status == 200 && body.contains("Valid token") {
165            debug!("JWT accepted");
166            return Ok(());
167        }
168
169        Err(crate::error::EnphaseError::AuthenticationFailed(
170            if body.is_empty() {
171                "Invalid token or authentication failed".to_owned()
172            } else {
173                format!("JWT check failed: {}", body.trim())
174            },
175        ))
176    }
177
178    /// Set the power state of an inverter or device.
179    ///
180    /// This sends a command to the Envoy device to enable or disable power
181    /// production on the specified device (identified by serial number).
182    ///
183    /// # Arguments
184    ///
185    /// * `serial` - The serial number of the device to control
186    /// * `state` - The desired power state (`PowerState::On` or `PowerState::Off`)
187    ///
188    /// # Returns
189    ///
190    /// Returns `Ok(())` if the power state change is successful.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if the request fails or the device does not respond
195    /// correctly.
196    ///
197    /// # Example
198    ///
199    /// ```no_run
200    /// use enphase_api::{Envoy, models::PowerState};
201    ///
202    /// # #[tokio::main]
203    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
204    /// let client = Envoy::new("envoy.local");
205    /// client.set_power_state("603980032", PowerState::Off).await?;
206    /// # Ok(())
207    /// # }
208    /// ```
209    #[inline]
210    #[instrument(skip(self, serial, state), level = "debug")]
211    pub async fn set_power_state(&self, serial: impl Display, state: PowerState) -> Result<()> {
212        debug!(?state, "Setting power state");
213
214        let endpoint = format!("{}/ivp/mod/{}/mode/power", self.base_url, serial);
215        debug!("PUT {endpoint}");
216
217        // Build the JSON payload
218        let payload = format!(r#"{{"length":1,"arr":[{}]}}"#, state.payload_value());
219
220        let response = self
221            .client
222            .put(&endpoint)
223            .header(
224                "Content-Type",
225                // This is not an error. Envoy expects the x-www-form-urlencoded
226                // content type, while the body is actually JSON.
227                "application/x-www-form-urlencoded; charset=UTF-8",
228            )
229            .body(payload)
230            .send()
231            .await?;
232
233        let status = response.status();
234        debug!("Status code: {}", status);
235
236        // The endpoint returns 204 No Content on success
237        if status == 204 {
238            debug!("Power state set successfully");
239            return Ok(());
240        }
241
242        Err(crate::error::EnphaseError::InvalidResponse(format!(
243            "Failed to set power state: HTTP {status}"
244        )))
245    }
246
247    /// Get the power state of an inverter or device.
248    ///
249    /// This retrieves the current power state from the Envoy device for the
250    /// specified device (identified by serial number).
251    ///
252    /// # Arguments
253    ///
254    /// * `serial` - The serial number of the device to query
255    ///
256    /// # Returns
257    ///
258    /// Returns `Ok(true)` if power is on, `Ok(false)` if power is off.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the request fails or the response cannot be parsed.
263    ///
264    /// # Example
265    ///
266    /// ```no_run
267    /// use enphase_api::Envoy;
268    ///
269    /// # #[tokio::main]
270    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
271    /// let client = Envoy::new("envoy.local");
272    /// let is_on = client.get_power_state("603980032").await?;
273    /// println!("Power is {}", if is_on { "on" } else { "off" });
274    /// # Ok(())
275    /// # }
276    /// ```
277    #[inline]
278    #[instrument(skip(self, serial), level = "debug")]
279    pub async fn get_power_state(&self, serial: impl Display) -> Result<bool> {
280        debug!("Getting power state");
281
282        let endpoint = format!("{}/ivp/mod/{}/mode/power", self.base_url, serial);
283        debug!("GET {endpoint}");
284
285        let response = self
286            .client
287            .get(&endpoint)
288            .header("Accept", "application/json, text/javascript, */*; q=0.01")
289            .send()
290            .await?;
291
292        let status_code = response.status();
293        debug!("Status code: {}", status_code);
294
295        let body = response.text().await?;
296        debug!("Response body: {}", body);
297
298        let status: PowerStatusResponse = serde_json::from_str(&body)?;
299        debug!(?status, "Parsed power status");
300
301        // powerForcedOff: true means power is OFF, so we invert it
302        Ok(!status.power_forced_off)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::models::PowerState;
310    use wiremock::matchers::{body_string, header, method, path};
311    use wiremock::{Mock, MockServer, ResponseTemplate};
312
313    // Helper to load fixture files
314    fn load_fixture(category: &str, name: &str) -> serde_json::Value {
315        let fixture_path = format!("fixtures/{category}/{name}.json");
316        let content = std::fs::read_to_string(&fixture_path)
317            .unwrap_or_else(|_| panic!("Failed to read fixture: {fixture_path}"));
318        serde_json::from_str(&content)
319            .unwrap_or_else(|_| panic!("Failed to parse fixture: {fixture_path}"))
320    }
321
322    #[tokio::test]
323    async fn authenticate_success() {
324        let mock_server = MockServer::start().await;
325
326        let fixture = load_fixture("envoy", "authenticate-valid");
327        let status_code: u16 = fixture
328            .get("status_code")
329            .and_then(serde_json::Value::as_u64)
330            .and_then(|v| v.try_into().ok())
331            .expect("status_code is not a valid u16");
332        let body = fixture
333            .get("body")
334            .and_then(serde_json::Value::as_str)
335            .expect("body is not a string")
336            .to_owned();
337
338        Mock::given(method("GET"))
339            .and(path("/auth/check_jwt"))
340            .and(header("Authorization", "Bearer valid_token_here"))
341            .respond_with(ResponseTemplate::new(status_code).set_body_string(&body))
342            .mount(&mock_server)
343            .await;
344
345        // Create a test client that works with HTTP (wiremock uses HTTP)
346        let test_client = reqwest::Client::builder()
347            .cookie_store(true)
348            .timeout(core::time::Duration::from_secs(30))
349            .build()
350            .expect("Failed to build test client");
351
352        // Create Envoy directly with mock server's HTTP URL
353        let client = Envoy {
354            client: test_client,
355            base_url: mock_server.uri(),
356        };
357
358        let result = client.authenticate("valid_token_here").await;
359
360        assert!(
361            result.is_ok(),
362            "Authentication should succeed with valid token. Error: {:?}",
363            result.err()
364        );
365    }
366
367    #[tokio::test]
368    async fn authenticate_invalid_token() {
369        let mock_server = MockServer::start().await;
370
371        let fixture = load_fixture("envoy", "authenticate-invalid");
372        let status_code: u16 = fixture
373            .get("status_code")
374            .and_then(serde_json::Value::as_u64)
375            .and_then(|v| v.try_into().ok())
376            .expect("status_code is not a valid u16");
377        let body = fixture
378            .get("body")
379            .and_then(serde_json::Value::as_str)
380            .expect("body is not a string")
381            .to_owned();
382
383        Mock::given(method("GET"))
384            .and(path("/auth/check_jwt"))
385            .respond_with(ResponseTemplate::new(status_code).set_body_string(&body))
386            .mount(&mock_server)
387            .await;
388
389        let test_client = reqwest::Client::builder()
390            .cookie_store(true)
391            .timeout(core::time::Duration::from_secs(30))
392            .build()
393            .expect("Failed to build test client");
394
395        let client = Envoy {
396            client: test_client,
397            base_url: mock_server.uri(),
398        };
399
400        let result = client.authenticate("invalid_token").await;
401
402        assert!(result.is_err(), "Should fail with invalid token");
403        if let Err(err) = result {
404            assert!(
405                matches!(err, crate::error::EnphaseError::AuthenticationFailed(_)),
406                "Error should be AuthenticationFailed type"
407            );
408        }
409    }
410
411    #[tokio::test]
412    async fn set_power_state() {
413        let mock_server = MockServer::start().await;
414
415        let fixture = load_fixture("envoy", "set-power-on");
416        let status_code: u16 = fixture
417            .get("status_code")
418            .and_then(serde_json::Value::as_u64)
419            .and_then(|v| v.try_into().ok())
420            .expect("status_code is not a valid u16");
421        let body = fixture
422            .get("body")
423            .and_then(serde_json::Value::as_str)
424            .expect("body is not a string")
425            .to_owned();
426
427        Mock::given(method("PUT"))
428            .and(path("/ivp/mod/603980032/mode/power"))
429            .and(header(
430                "Content-Type",
431                "application/x-www-form-urlencoded; charset=UTF-8",
432            ))
433            .and(body_string(r#"{"length":1,"arr":[0]}"#))
434            .respond_with(ResponseTemplate::new(status_code).set_body_string(&body))
435            .mount(&mock_server)
436            .await;
437
438        let test_client = reqwest::Client::builder()
439            .cookie_store(true)
440            .timeout(core::time::Duration::from_secs(30))
441            .build()
442            .expect("Failed to build test client");
443
444        let client = Envoy {
445            client: test_client,
446            base_url: mock_server.uri(),
447        };
448
449        let result = client.set_power_state("603980032", PowerState::On).await;
450
451        assert!(result.is_ok(), "Setting power state to ON should succeed");
452    }
453
454    #[tokio::test]
455    async fn get_power_state() {
456        let mock_server = MockServer::start().await;
457
458        let fixture = load_fixture("envoy", "get-power");
459        let status_code: u16 = fixture
460            .get("status_code")
461            .and_then(serde_json::Value::as_u64)
462            .and_then(|v| v.try_into().ok())
463            .expect("status_code is not a valid u16");
464        let response_body = fixture
465            .get("body")
466            .and_then(serde_json::Value::as_str)
467            .expect("body is not a string")
468            .to_owned();
469
470        Mock::given(method("GET"))
471            .and(path("/ivp/mod/603980032/mode/power"))
472            .respond_with(ResponseTemplate::new(status_code).set_body_string(&response_body))
473            .mount(&mock_server)
474            .await;
475
476        let test_client = reqwest::Client::builder()
477            .cookie_store(true)
478            .timeout(core::time::Duration::from_secs(30))
479            .build()
480            .expect("Failed to build test client");
481
482        let client = Envoy {
483            client: test_client,
484            base_url: mock_server.uri(),
485        };
486
487        let is_on = client
488            .get_power_state("603980032")
489            .await
490            .expect("Should succeed");
491
492        assert!(is_on, "Power should be ON when powerForcedOff is false");
493    }
494
495    #[tokio::test]
496    async fn get_power_state_invalid_json() {
497        let mock_server = MockServer::start().await;
498
499        Mock::given(method("GET"))
500            .and(path("/ivp/mod/603980032/mode/power"))
501            .respond_with(ResponseTemplate::new(200).set_body_string("Invalid JSON response"))
502            .mount(&mock_server)
503            .await;
504
505        let test_client = reqwest::Client::builder()
506            .cookie_store(true)
507            .timeout(core::time::Duration::from_secs(30))
508            .build()
509            .expect("Failed to build test client");
510
511        let client = Envoy {
512            client: test_client,
513            base_url: mock_server.uri(),
514        };
515
516        let result = client.get_power_state("603980032").await;
517
518        assert!(result.is_err(), "Should fail with invalid JSON");
519        if let Err(err) = result {
520            assert!(
521                matches!(err, crate::error::EnphaseError::JsonError(_)),
522                "Error should be JsonError type"
523            );
524        }
525    }
526}