1use chrono::{NaiveDate, NaiveDateTime};
2use reqwest::blocking::Client;
3use serde::{Deserialize, Serialize};
4use sha1::{Digest, Sha1};
5use std::collections::HashSet;
6use std::fmt;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9mod actions;
10
11pub type ShineMonitorAPIResult = Result<serde_json::Value, ApiError>;
12
13fn auth_err_codes() -> HashSet<i64> {
16 [0x0007, 0x000F, 0x0010, 0x0019, 0x0105, 0x010E]
17 .into_iter()
18 .collect()
19}
20
21#[derive(Debug, Clone)]
25pub struct ApiError {
26 pub err: i64,
27 pub desc: String,
28 pub payload: serde_json::Value,
29}
30
31impl ApiError {
32 pub fn is_auth(&self) -> bool {
33 auth_err_codes().contains(&self.err)
34 }
35
36 fn from_payload(payload: serde_json::Value) -> Self {
37 let err = payload.get("err").and_then(|v| v.as_i64()).unwrap_or(-1);
38 let desc = payload
39 .get("desc")
40 .and_then(|v| v.as_str())
41 .unwrap_or("")
42 .to_string();
43 ApiError { err, desc, payload }
44 }
45
46 fn local(err: i64, desc: impl Into<String>) -> Self {
47 ApiError {
48 err,
49 desc: desc.into(),
50 payload: serde_json::Value::Null,
51 }
52 }
53}
54
55impl fmt::Display for ApiError {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 write!(
58 f,
59 "shinemonitor: err=0x{:04X} desc={:?}",
60 self.err, self.desc
61 )
62 }
63}
64
65impl std::error::Error for ApiError {}
66
67impl From<reqwest::Error> for ApiError {
68 fn from(e: reqwest::Error) -> Self {
69 ApiError::local(-1, format!("transport: {e}"))
70 }
71}
72
73#[derive(Debug, Serialize, Clone)]
74struct ShineMonitorDeviceParams {
75 serial_number: String,
76 wifi_pn: String,
77 dev_code: i32,
78 dev_addr: i32,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DeviceIdentifier {
86 #[serde(alias = "devalias")]
87 pub device_alias: Option<String>,
88 #[serde(alias = "sn")]
89 pub serial_number: String,
90 #[serde(alias = "pn")]
91 pub wifi_pin: String,
92 #[serde(alias = "devaddr")]
93 pub device_address: i64,
94 #[serde(alias = "devcode")]
95 pub device_code: i64,
96}
97
98#[derive(Debug, Clone, PartialEq)]
103pub struct AppProfile {
104 pub app_id: &'static str,
105 pub app_version: &'static str,
106 pub company_key: &'static str,
107 pub base_url: &'static str,
108 pub locale: &'static str,
109 pub source: i32,
110 pub app_client: &'static str,
111}
112
113impl AppProfile {
114 pub fn suffix_context(&self) -> String {
116 format!(
117 "&i18n={}&lang={}&source={}&_app_client_={}&_app_id_={}&_app_version_={}",
118 self.locale, self.locale, self.source, self.app_client, self.app_id, self.app_version
119 )
120 }
121
122 pub const WATCHPOWER: AppProfile = AppProfile {
124 app_id: "wifiapp.volfw.watchpower",
125 app_version: "1.0.6.3",
126 company_key: "bnrl_frRFjEz8Mkn",
127 base_url: "http://android.shinemonitor.com/public/",
128 locale: "pt_BR",
129 source: 1,
130 app_client: "android",
131 };
132
133 pub const RENOCLIENT: AppProfile = AppProfile {
135 app_id: "com.eybond.renoclient",
136 app_version: "1.3.2.0",
137 company_key: "bnrl_frRFjEz8Mkn",
138 base_url: "http://android.shinemonitor.com/public/",
139 locale: "pt_BR",
140 source: 1,
141 app_client: "android",
142 };
143}
144
145pub const KNOWN_APPS: &[AppProfile] = &[AppProfile::WATCHPOWER, AppProfile::RENOCLIENT];
149
150#[derive(Debug, Serialize, Clone)]
151pub struct ShineMonitorLastDataGrid {
152 pub grid_rating_voltage: f32,
153 pub grid_rating_current: f32,
154 pub battery_rating_voltage: f32,
155 pub ac_output_rating_voltage: f32,
156 pub ac_output_rating_current: f32,
157 pub ac_output_rating_frequency: f32,
158 pub ac_output_rating_apparent_power: i32,
159 pub ac_output_rating_active_power: i32,
160}
161
162impl ShineMonitorLastDataGrid {
163 fn from_json(json: &serde_json::Value) -> Self {
164 let mut grid_rating_voltage = None;
165 let mut grid_rating_current = None;
166 let mut battery_rating_voltage = None;
167 let mut ac_output_rating_voltage = None;
168 let mut ac_output_rating_current = None;
169 let mut ac_output_rating_frequency = None;
170 let mut ac_output_rating_apparent_power = None;
171 let mut ac_output_rating_active_power = None;
172
173 for field in json.as_array().unwrap() {
174 match field["id"].as_str().unwrap() {
175 "gd_grid_rating_voltage" => {
176 grid_rating_voltage =
177 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
178 }
179 "gd_grid_rating_current" => {
180 grid_rating_current =
181 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
182 }
183 "gd_battery_rating_voltage" => {
184 battery_rating_voltage =
185 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
186 }
187 "gd_bse_input_voltage_read" => {
188 ac_output_rating_voltage =
189 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
190 }
191 "gd_ac_output_rating_current" => {
192 ac_output_rating_current =
193 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
194 }
195 "gd_bse_output_frequency_read" => {
196 ac_output_rating_frequency =
197 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
198 }
199 "gd_ac_output_rating_apparent_power" => {
200 ac_output_rating_apparent_power =
201 Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
202 }
203 "gd_ac_output_rating_active_power" => {
204 ac_output_rating_active_power =
205 Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
206 }
207 _ => continue,
208 }
209 }
210 ShineMonitorLastDataGrid {
211 grid_rating_voltage: grid_rating_voltage.expect("Grid rating voltage not found"),
212 grid_rating_current: grid_rating_current.expect("Grid rating current not found"),
213 battery_rating_voltage: battery_rating_voltage
214 .expect("Battery rating voltage not found"),
215 ac_output_rating_voltage: ac_output_rating_voltage
216 .expect("AC output rating voltage not found"),
217 ac_output_rating_current: ac_output_rating_current
218 .expect("AC output rating current not found"),
219 ac_output_rating_frequency: ac_output_rating_frequency
220 .expect("AC output rating frequency not found"),
221 ac_output_rating_apparent_power: ac_output_rating_apparent_power
222 .expect("AC output rating apparent power not found"),
223 ac_output_rating_active_power: ac_output_rating_active_power
224 .expect("AC output rating active power not found"),
225 }
226 }
227}
228
229#[derive(Debug, Serialize, Clone)]
230pub struct ShineMonitorLastDataSystem {
231 pub model: String,
232 pub main_cpu_firmware_version: String,
233 pub secondary_cpu_firmware_version: String,
234}
235
236impl ShineMonitorLastDataSystem {
237 fn from_json(json: &serde_json::Value) -> Self {
238 let mut model = None;
239 let mut main_cpu_firmware_version = None;
240 let mut secondary_cpu_firmware_version = None;
241
242 for field in json.as_array().unwrap() {
243 match field["id"].as_str().unwrap() {
244 "sy_model" => model = Some(field["val"].as_str().unwrap().to_owned()),
245 "sy_main_cpu1_firmware_version" => {
246 main_cpu_firmware_version = Some(field["val"].as_str().unwrap().to_owned())
247 }
248 "sy_main_cpu2_firmware_version" => {
249 secondary_cpu_firmware_version = Some(field["val"].as_str().unwrap().to_owned())
250 }
251 _ => continue,
252 }
253 }
254 ShineMonitorLastDataSystem {
255 model: model.expect("Model not found"),
256 main_cpu_firmware_version: main_cpu_firmware_version
257 .expect("Main CPU firmware version not found"),
258 secondary_cpu_firmware_version: secondary_cpu_firmware_version
259 .expect("Secondary CPU firmware version not found"),
260 }
261 }
262}
263
264#[derive(Debug, Serialize, Clone)]
265pub struct ShineMonitorLastDataPV {
266 pub pv_input_current: f32,
267}
268
269impl ShineMonitorLastDataPV {
270 fn from_json(json: &serde_json::Value) -> Self {
271 let mut pv_input_current = None;
272 for field in json.as_array().unwrap() {
273 match field["id"].as_str().unwrap() {
274 "pv_input_current" => {
275 pv_input_current = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
276 }
277 _ => continue,
278 }
279 }
280 ShineMonitorLastDataPV {
281 pv_input_current: pv_input_current.expect("PV input current not found"),
282 }
283 }
284}
285
286#[derive(Debug, Serialize, Clone)]
287pub struct ShineMonitorLastDataMain {
288 pub grid_voltage: f32,
289 pub grid_frequency: f32,
290 pub pv_input_voltage: f32,
291 pub pv_input_power: i16,
292 pub battery_voltage: f32,
293 pub battery_capacity: i8,
294 pub battery_charging_current: f32,
295 pub battery_discharge_current: f32,
296 pub ac_output_voltage: f32,
297 pub ac_output_frequency: f32,
298 pub ac_output_apparent_power: i32,
299 pub ac_output_active_power: i32,
300 pub output_load_percent: i8,
301}
302
303impl ShineMonitorLastDataMain {
304 fn from_json(json: &serde_json::Value) -> Self {
305 let mut grid_voltage = None;
306 let mut grid_frequency = None;
307 let mut pv_input_voltage = None;
308 let mut pv_input_power = None;
309 let mut battery_voltage = None;
310 let mut battery_capacity = None;
311 let mut battery_charging_current = None;
312 let mut battery_discharge_current = None;
313 let mut ac_output_voltage = None;
314 let mut ac_output_frequency = None;
315 let mut ac_output_apparent_power = None;
316 let mut ac_output_active_power = None;
317 let mut output_load_percent = None;
318 for field in json.as_array().unwrap() {
319 match field["id"].as_str().unwrap() {
320 "bt_grid_voltage" => {
321 grid_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
322 }
323 "bt_grid_frequency" => {
324 grid_frequency = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
325 }
326 "bt_voltage_1" => {
327 pv_input_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
328 }
329 "bt_input_power" => {
330 pv_input_power = Some(field["val"].as_str().unwrap().parse::<i16>().unwrap())
331 }
332 "bt_battery_voltage" => {
333 battery_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
334 }
335 "bt_battery_capacity" => {
336 battery_capacity = Some(field["val"].as_str().unwrap().parse::<i8>().unwrap())
337 }
338 "bt_battery_charging_current" => {
339 battery_charging_current =
340 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
341 }
342 "bt_battery_discharge_current" => {
343 battery_discharge_current =
344 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
345 }
346 "bt_ac_output_voltage" => {
347 ac_output_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
348 }
349 "bt_grid_AC_frequency" => {
350 ac_output_frequency =
351 Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
352 }
353 "bt_ac_output_apparent_power" => {
354 ac_output_apparent_power =
355 Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
356 }
357 "bt_load_active_power_sole" => {
358 ac_output_active_power =
359 Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
360 }
361 "bt_output_load_percent" => {
362 output_load_percent =
363 Some(field["val"].as_str().unwrap().parse::<i8>().unwrap())
364 }
365 _ => continue,
366 }
367 }
368 ShineMonitorLastDataMain {
369 grid_voltage: grid_voltage.expect("Grid voltage not found"),
370 grid_frequency: grid_frequency.expect("Grid frequency not found"),
371 pv_input_voltage: pv_input_voltage.expect("PV input voltage not found"),
372 pv_input_power: pv_input_power.expect("PV input power not found"),
373 battery_voltage: battery_voltage.expect("Battery voltage not found"),
374 battery_capacity: battery_capacity.expect("Battery capacity not found"),
375 battery_charging_current: battery_charging_current
376 .expect("Battery charging current not found"),
377 battery_discharge_current: battery_discharge_current
378 .expect("Battery discharge current not found"),
379 ac_output_voltage: ac_output_voltage.expect("AC output voltage not found"),
380 ac_output_frequency: ac_output_frequency.expect("AC output frequency not found"),
381 ac_output_apparent_power: ac_output_apparent_power
382 .expect("AC output apparent power not found"),
383 ac_output_active_power: ac_output_active_power
384 .expect("AC output active power not found"),
385 output_load_percent: output_load_percent.expect("Output load percent not found"),
386 }
387 }
388}
389
390#[derive(Debug, Serialize, Clone)]
391pub struct ShineMonitorLastData {
392 pub timestamp: NaiveDateTime,
393 pub grid: ShineMonitorLastDataGrid,
394 pub system: ShineMonitorLastDataSystem,
395 pub pv: ShineMonitorLastDataPV,
396 pub main: ShineMonitorLastDataMain,
397}
398
399impl ShineMonitorLastData {
400 fn from_json(json: &serde_json::Value) -> Self {
401 let dat_field = &json["dat"];
402 let pars_field = &dat_field["pars"];
403 let all_fields = pars_field
404 .as_object()
405 .map(|obj| {
406 obj.values()
407 .filter(|v| v.is_array())
408 .flat_map(|v| v.as_array().unwrap().iter().cloned())
409 .collect::<Vec<_>>()
410 })
411 .unwrap_or_default();
412 let all = serde_json::Value::Array(all_fields);
413 ShineMonitorLastData {
414 timestamp: parse_gts(&dat_field["gts"]),
415 grid: ShineMonitorLastDataGrid::from_json(&all),
416 system: ShineMonitorLastDataSystem::from_json(&all),
417 pv: ShineMonitorLastDataPV::from_json(&all),
418 main: ShineMonitorLastDataMain::from_json(&all),
419 }
420 }
421}
422
423fn parse_gts(value: &serde_json::Value) -> NaiveDateTime {
426 if let Some(raw) = value.as_str() {
427 let trimmed = raw.trim();
428 if let Ok(ms) = trimmed.parse::<i64>() {
429 return chrono::DateTime::from_timestamp_millis(ms)
430 .expect("valid epoch ms")
431 .naive_utc();
432 }
433 return NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S")
434 .expect("valid gts string");
435 }
436 if let Some(ms) = value.as_i64() {
437 return chrono::DateTime::from_timestamp_millis(ms)
438 .expect("valid epoch ms")
439 .naive_utc();
440 }
441 panic!("unexpected gts value: {value:?}");
442}
443
444#[derive(Debug, Clone)]
445pub struct ShineMonitorAPI {
446 _base_url: String,
447 _suffix_context: String,
448 _company_key: String,
449 _app_profile: AppProfile,
450 _token: Option<String>,
451 _secret: String,
452 _expire: Option<u64>,
453 _client: Client,
454 _device_params: ShineMonitorDeviceParams,
455}
456
457impl ShineMonitorAPI {
458 pub fn new(serial_number: &str, wifi_pn: &str, dev_code: i32, dev_addr: i32) -> Self {
459 ShineMonitorAPI {
460 _base_url: AppProfile::WATCHPOWER.base_url.to_string(),
461 _suffix_context: AppProfile::WATCHPOWER.suffix_context(),
462 _company_key: AppProfile::WATCHPOWER.company_key.to_string(),
463 _app_profile: AppProfile::WATCHPOWER,
464 _token: None,
465 _secret: "ems_secret".to_string(),
466 _expire: None,
467 _client: Client::new(),
468 _device_params: ShineMonitorDeviceParams {
469 serial_number: serial_number.to_string(),
470 wifi_pn: wifi_pn.to_string(),
471 dev_code,
472 dev_addr,
473 },
474 }
475 }
476
477 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
478 self._base_url = base_url.into();
479 self
480 }
481
482 pub fn with_suffix_context(mut self, suffix: impl Into<String>) -> Self {
483 self._suffix_context = suffix.into();
484 self
485 }
486
487 pub fn with_company_key(mut self, key: impl Into<String>) -> Self {
488 self._company_key = key.into();
489 self
490 }
491
492 pub fn with_app_profile(mut self, profile: AppProfile) -> Self {
495 self._app_profile = profile.clone();
496 self._base_url = profile.base_url.to_string();
497 self._suffix_context = profile.suffix_context();
498 self._company_key = profile.company_key.to_string();
499 self
500 }
501
502 fn generate_salt() -> String {
503 let start = SystemTime::now();
504 let since_the_epoch = start
505 .duration_since(UNIX_EPOCH)
506 .expect("Time went backwards");
507 (since_the_epoch.as_millis()).to_string()
508 }
509
510 fn sha1_str_lower_case(input: &[u8]) -> String {
511 let mut hasher = Sha1::new();
512 hasher.update(input);
513 format!("{:x}", hasher.finalize())
514 }
515
516 fn hash(&self, args: Vec<&str>) -> String {
517 let arg_concat = args.join("");
518 ShineMonitorAPI::sha1_str_lower_case(arg_concat.as_bytes())
519 }
520
521 fn encode_username(username: &str) -> String {
528 username.replace(' ', "+")
529 }
530
531 pub fn login(&mut self, username: &str, password: &str) -> Result<(), ApiError> {
532 let base_action = format!(
533 "&action=authSource&usr={}&company-key={}{}",
534 ShineMonitorAPI::encode_username(username),
535 self._company_key,
536 self._suffix_context
537 );
538
539 let salt = ShineMonitorAPI::generate_salt();
540 let password_hash = self.hash(vec![password]);
541 let sign = self.hash(vec![&salt, &password_hash, &base_action]);
542
543 let url = format!(
544 "{}?sign={}&salt={}{}",
545 self._base_url, sign, salt, base_action
546 );
547
548 let response: serde_json::Value = self._client.get(&url).send()?.json()?;
549
550 if response["err"].as_i64() == Some(0) {
551 self._secret = response["dat"]["secret"].as_str().unwrap().to_string();
552 self._token = Some(response["dat"]["token"].as_str().unwrap().to_string());
553 self._expire = Some(response["dat"]["expire"].as_u64().unwrap());
554 Ok(())
555 } else {
556 Err(ApiError::from_payload(response))
557 }
558 }
559
560 fn _request(&self, action: &str, query: Option<&str>) -> ShineMonitorAPIResult {
561 let base_action = format!(
562 "&action={}&pn={}&devcode={}&sn={}&devaddr={}{}{}",
563 action,
564 self._device_params.wifi_pn,
565 self._device_params.dev_code,
566 self._device_params.serial_number,
567 self._device_params.dev_addr,
568 query.unwrap_or(""),
569 self._suffix_context
570 );
571 self._request_raw(&base_action)
572 }
573
574 pub fn _request_with(&self, action: &str, extra: &str) -> ShineMonitorAPIResult {
578 let base_action = format!("&action={}{}{}", action, extra, self._suffix_context);
579 self._request_raw(&base_action)
580 }
581
582 fn _request_raw(&self, base_action: &str) -> ShineMonitorAPIResult {
583 let token = self
584 ._token
585 .as_ref()
586 .ok_or_else(|| ApiError::local(-1, "not logged in"))?;
587 let salt = ShineMonitorAPI::generate_salt();
588 let sign = self.hash(vec![&salt, &self._secret, token, base_action]);
589 let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
590 let url = format!("{}{}{}", self._base_url, auth, base_action);
591
592 let response: serde_json::Value = self._client.get(&url).send()?.json()?;
593
594 if response["err"].as_i64() == Some(0) {
595 Ok(response)
596 } else {
597 Err(ApiError::from_payload(response))
598 }
599 }
600
601 pub fn get_daily_data(&self, day: NaiveDate) -> Result<serde_json::Value, ApiError> {
602 let _date = day.format("%Y-%m-%d").to_string();
603 let query = format!("&date={}", _date);
604 self._request("queryDeviceDataOneDay", Some(&query))
605 }
606
607 pub fn get_last_data(&self) -> Result<ShineMonitorLastData, ApiError> {
608 let raw = self._request("querySPDeviceLastData", None)?;
609 Ok(ShineMonitorLastData::from_json(&raw))
610 }
611
612 pub fn get_devices(&self) -> Result<Vec<DeviceIdentifier>, ApiError> {
618 let token = self
619 ._token
620 .as_ref()
621 .ok_or_else(|| ApiError::local(-1, "not logged in"))?;
622 let mut last_err: Option<ApiError> = None;
623
624 let current_id = self._app_profile.app_id;
625 let mut profiles: Vec<&AppProfile> = vec![&self._app_profile];
626 for known in KNOWN_APPS {
627 if known.app_id != current_id {
628 profiles.push(known);
629 }
630 }
631
632 for profile in profiles {
633 let suffix = profile.suffix_context();
634 let base_action = format!("&action=webQueryDeviceEs{}", suffix);
635 let salt = ShineMonitorAPI::generate_salt();
636 let sign = self.hash(vec![&salt, &self._secret, token, &base_action]);
637 let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
638 let url = format!("{}{}{}", self._base_url, auth, base_action);
639
640 let response: serde_json::Value = match self._client.get(&url).send() {
641 Ok(r) => match r.json() {
642 Ok(v) => v,
643 Err(e) => return Err(ApiError::from(e)),
644 },
645 Err(e) => return Err(ApiError::from(e)),
646 };
647
648 if response["err"].as_i64() == Some(0) {
649 let devices: Vec<DeviceIdentifier> = response["dat"]["device"]
650 .as_array()
651 .map(|arr| {
652 arr.iter()
653 .filter_map(|v| serde_json::from_value(v.clone()).ok())
654 .collect()
655 })
656 .unwrap_or_default();
657 return Ok(devices);
658 }
659
660 let api_err = ApiError::from_payload(response);
661 if api_err.err != 0x0102 {
662 return Err(api_err);
663 }
664 last_err = Some(api_err);
665 }
666
667 Err(last_err.unwrap_or_else(|| {
668 ApiError::local(258, "all app profiles returned ERR_NOT_FOUND_DEVICE")
669 }))
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::ShineMonitorLastData;
676
677 #[test]
678 fn extracts_telemetry_across_separate_block_arrays() {
679 let json = serde_json::json!({
680 "err": 0,
681 "desc": "ERR_NONE",
682 "dat": {
683 "gts": "2026-08-05 14:37:29",
684 "pars": {
685 "gd_": [
686 {"id": "gd_grid_rating_voltage", "val": "230.0"},
687 {"id": "gd_grid_rating_current", "val": "30.0"},
688 {"id": "gd_battery_rating_voltage", "val": "48.0"},
689 {"id": "gd_bse_input_voltage_read", "val": "230.0"},
690 {"id": "gd_ac_output_rating_current", "val": "30.0"},
691 {"id": "gd_bse_output_frequency_read", "val": "50.0"},
692 {"id": "gd_ac_output_rating_apparent_power", "val": "5000"},
693 {"id": "gd_ac_output_rating_active_power", "val": "5000"},
694 ],
695 "sy_": [
696 {"id": "sy_model", "val": "Off Grid"},
697 {"id": "sy_main_cpu1_firmware_version", "val": "01.23"},
698 {"id": "sy_main_cpu2_firmware_version", "val": "04.56"},
699 ],
700 "pv_": [
701 {"id": "pv_input_current", "val": "12.5"},
702 {"id": "bt_voltage_1", "val": "129.4"},
703 {"id": "bt_input_power", "val": "0"},
704 ],
705 "bt_": [
706 {"id": "bt_grid_voltage", "val": "0.0"},
707 {"id": "bt_grid_frequency", "val": "50.0"},
708 {"id": "bt_battery_voltage", "val": "49.3"},
709 {"id": "bt_battery_capacity", "val": "71"},
710 {"id": "bt_battery_charging_current", "val": "0.0"},
711 {"id": "bt_battery_discharge_current", "val": "0.0"},
712 {"id": "bt_ac_output_apparent_power", "val": "1200"},
713 ],
714 "bc_": [
715 {"id": "bt_ac_output_voltage", "val": "230.0"},
716 {"id": "bt_grid_AC_frequency", "val": "50.0"},
717 {"id": "bt_load_active_power_sole", "val": "533"},
718 {"id": "bt_output_load_percent", "val": "12"},
719 ],
720 },
721 },
722 });
723 let snapshot = ShineMonitorLastData::from_json(&json);
724 assert!((snapshot.main.pv_input_voltage - 129.4).abs() < 0.001);
725 assert_eq!(snapshot.main.pv_input_power, 0);
726 assert_eq!(snapshot.main.battery_capacity, 71);
727 assert!((snapshot.main.ac_output_voltage - 230.0).abs() < 0.001);
728 assert!((snapshot.main.ac_output_frequency - 50.0).abs() < 0.001);
729 assert_eq!(snapshot.main.ac_output_active_power, 533);
730 assert_eq!(snapshot.main.output_load_percent, 12);
731 }
732}