enphase_api/client/
envoy.rs1use core::fmt::Display;
18
19use crate::{
20 error::Result,
21 models::{PowerState, PowerStatusResponse},
22};
23use tracing::{debug, instrument};
24
25#[derive(Debug, Clone)]
30pub struct Envoy {
31 client: reqwest::Client,
33 base_url: String,
35}
36
37impl Envoy {
38 #[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 #[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 #[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 #[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 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 "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 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 #[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 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 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 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 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}