Skip to main content

shinemonitor_api/
lib.rs

1use chrono::{NaiveDate, NaiveDateTime};
2use reqwest::blocking::Client;
3use serde::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
13/// Documented auth-band error codes from chapter 2 (auth.html). Hex in
14/// docs, integers on the wire. Mirrors the python and Go clients.
15fn auth_err_codes() -> HashSet<i64> {
16    [0x0007, 0x000F, 0x0010, 0x0019, 0x0105, 0x010E]
17        .into_iter()
18        .collect()
19}
20
21/// Structured API error mirroring the python `ShineMonitorError` and Go
22/// `APIError`. `err == 0` is success and never produces this; non-zero
23/// `err` codes raise.
24#[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, Serialize, Clone)]
82pub struct ShineMonitorLastDataGrid {
83    pub grid_rating_voltage: f32,
84    pub grid_rating_current: f32,
85    pub battery_rating_voltage: f32,
86    pub ac_output_rating_voltage: f32,
87    pub ac_output_rating_current: f32,
88    pub ac_output_rating_frequency: f32,
89    pub ac_output_rating_apparent_power: i32,
90    pub ac_output_rating_active_power: i32,
91}
92
93impl ShineMonitorLastDataGrid {
94    fn from_json(json: &serde_json::Value) -> Self {
95        let mut grid_rating_voltage = None;
96        let mut grid_rating_current = None;
97        let mut battery_rating_voltage = None;
98        let mut ac_output_rating_voltage = None;
99        let mut ac_output_rating_current = None;
100        let mut ac_output_rating_frequency = None;
101        let mut ac_output_rating_apparent_power = None;
102        let mut ac_output_rating_active_power = None;
103
104        for field in json.as_array().unwrap() {
105            match field["id"].as_str().unwrap() {
106                "gd_grid_rating_voltage" => {
107                    grid_rating_voltage =
108                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
109                }
110                "gd_grid_rating_current" => {
111                    grid_rating_current =
112                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
113                }
114                "gd_battery_rating_voltage" => {
115                    battery_rating_voltage =
116                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
117                }
118                "gd_bse_input_voltage_read" => {
119                    ac_output_rating_voltage =
120                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
121                }
122                "gd_ac_output_rating_current" => {
123                    ac_output_rating_current =
124                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
125                }
126                "gd_bse_output_frequency_read" => {
127                    ac_output_rating_frequency =
128                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
129                }
130                "gd_ac_output_rating_apparent_power" => {
131                    ac_output_rating_apparent_power =
132                        Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
133                }
134                "gd_ac_output_rating_active_power" => {
135                    ac_output_rating_active_power =
136                        Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
137                }
138                _ => continue,
139            }
140        }
141        ShineMonitorLastDataGrid {
142            grid_rating_voltage: grid_rating_voltage.expect("Grid rating voltage not found"),
143            grid_rating_current: grid_rating_current.expect("Grid rating current not found"),
144            battery_rating_voltage: battery_rating_voltage
145                .expect("Battery rating voltage not found"),
146            ac_output_rating_voltage: ac_output_rating_voltage
147                .expect("AC output rating voltage not found"),
148            ac_output_rating_current: ac_output_rating_current
149                .expect("AC output rating current not found"),
150            ac_output_rating_frequency: ac_output_rating_frequency
151                .expect("AC output rating frequency not found"),
152            ac_output_rating_apparent_power: ac_output_rating_apparent_power
153                .expect("AC output rating apparent power not found"),
154            ac_output_rating_active_power: ac_output_rating_active_power
155                .expect("AC output rating active power not found"),
156        }
157    }
158}
159
160#[derive(Debug, Serialize, Clone)]
161pub struct ShineMonitorLastDataSystem {
162    pub model: String,
163    pub main_cpu_firmware_version: String,
164    pub secondary_cpu_firmware_version: String,
165}
166
167impl ShineMonitorLastDataSystem {
168    fn from_json(json: &serde_json::Value) -> Self {
169        let mut model = None;
170        let mut main_cpu_firmware_version = None;
171        let mut secondary_cpu_firmware_version = None;
172
173        for field in json.as_array().unwrap() {
174            match field["id"].as_str().unwrap() {
175                "sy_model" => model = Some(field["val"].as_str().unwrap().to_owned()),
176                "sy_main_cpu1_firmware_version" => {
177                    main_cpu_firmware_version = Some(field["val"].as_str().unwrap().to_owned())
178                }
179                "sy_main_cpu2_firmware_version" => {
180                    secondary_cpu_firmware_version = Some(field["val"].as_str().unwrap().to_owned())
181                }
182                _ => continue,
183            }
184        }
185        ShineMonitorLastDataSystem {
186            model: model.expect("Model not found"),
187            main_cpu_firmware_version: main_cpu_firmware_version
188                .expect("Main CPU firmware version not found"),
189            secondary_cpu_firmware_version: secondary_cpu_firmware_version
190                .expect("Secondary CPU firmware version not found"),
191        }
192    }
193}
194
195#[derive(Debug, Serialize, Clone)]
196pub struct ShineMonitorLastDataPV {
197    pub pv_input_current: f32,
198}
199
200impl ShineMonitorLastDataPV {
201    fn from_json(json: &serde_json::Value) -> Self {
202        let mut pv_input_current = None;
203        for field in json.as_array().unwrap() {
204            match field["id"].as_str().unwrap() {
205                "pv_input_current" => {
206                    pv_input_current = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
207                }
208                _ => continue,
209            }
210        }
211        ShineMonitorLastDataPV {
212            pv_input_current: pv_input_current.expect("PV input current not found"),
213        }
214    }
215}
216
217#[derive(Debug, Serialize, Clone)]
218pub struct ShineMonitorLastDataMain {
219    pub grid_voltage: f32,
220    pub grid_frequency: f32,
221    pub pv_input_voltage: f32,
222    pub pv_input_power: i16,
223    pub battery_voltage: f32,
224    pub battery_capacity: i8,
225    pub battery_charging_current: f32,
226    pub battery_discharge_current: f32,
227    pub ac_output_voltage: f32,
228    pub ac_output_frequency: f32,
229    pub ac_output_apparent_power: i32,
230    pub ac_output_active_power: i32,
231    pub output_load_percent: i8,
232}
233
234impl ShineMonitorLastDataMain {
235    fn from_json(json: &serde_json::Value) -> Self {
236        let mut grid_voltage = None;
237        let mut grid_frequency = None;
238        let mut pv_input_voltage = None;
239        let mut pv_input_power = None;
240        let mut battery_voltage = None;
241        let mut battery_capacity = None;
242        let mut battery_charging_current = None;
243        let mut battery_discharge_current = None;
244        let mut ac_output_voltage = None;
245        let mut ac_output_frequency = None;
246        let mut ac_output_apparent_power = None;
247        let mut ac_output_active_power = None;
248        let mut output_load_percent = None;
249        for field in json.as_array().unwrap() {
250            match field["id"].as_str().unwrap() {
251                "bt_grid_voltage" => {
252                    grid_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
253                }
254                "bt_grid_frequency" => {
255                    grid_frequency = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
256                }
257                "bt_voltage_1" => {
258                    pv_input_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
259                }
260                "bt_input_power" => {
261                    pv_input_power = Some(field["val"].as_str().unwrap().parse::<i16>().unwrap())
262                }
263                "bt_battery_voltage" => {
264                    battery_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
265                }
266                "bt_battery_capacity" => {
267                    battery_capacity = Some(field["val"].as_str().unwrap().parse::<i8>().unwrap())
268                }
269                "bt_battery_charging_current" => {
270                    battery_charging_current =
271                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
272                }
273                "bt_battery_discharge_current" => {
274                    battery_discharge_current =
275                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
276                }
277                "bt_ac_output_voltage" => {
278                    ac_output_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
279                }
280                "bt_grid_AC_frequency" => {
281                    ac_output_frequency =
282                        Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
283                }
284                "bt_ac_output_apparent_power" => {
285                    ac_output_apparent_power =
286                        Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
287                }
288                "bt_load_active_power_sole" => {
289                    ac_output_active_power =
290                        Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
291                }
292                "bt_output_load_percent" => {
293                    output_load_percent =
294                        Some(field["val"].as_str().unwrap().parse::<i8>().unwrap())
295                }
296                _ => continue,
297            }
298        }
299        ShineMonitorLastDataMain {
300            grid_voltage: grid_voltage.expect("Grid voltage not found"),
301            grid_frequency: grid_frequency.expect("Grid frequency not found"),
302            pv_input_voltage: pv_input_voltage.expect("PV input voltage not found"),
303            pv_input_power: pv_input_power.expect("PV input power not found"),
304            battery_voltage: battery_voltage.expect("Battery voltage not found"),
305            battery_capacity: battery_capacity.expect("Battery capacity not found"),
306            battery_charging_current: battery_charging_current
307                .expect("Battery charging current not found"),
308            battery_discharge_current: battery_discharge_current
309                .expect("Battery discharge current not found"),
310            ac_output_voltage: ac_output_voltage.expect("AC output voltage not found"),
311            ac_output_frequency: ac_output_frequency.expect("AC output frequency not found"),
312            ac_output_apparent_power: ac_output_apparent_power
313                .expect("AC output apparent power not found"),
314            ac_output_active_power: ac_output_active_power
315                .expect("AC output active power not found"),
316            output_load_percent: output_load_percent.expect("Output load percent not found"),
317        }
318    }
319}
320
321#[derive(Debug, Serialize, Clone)]
322pub struct ShineMonitorLastData {
323    pub timestamp: NaiveDateTime,
324    pub grid: ShineMonitorLastDataGrid,
325    pub system: ShineMonitorLastDataSystem,
326    pub pv: ShineMonitorLastDataPV,
327    pub main: ShineMonitorLastDataMain,
328}
329
330impl ShineMonitorLastData {
331    fn from_json(json: &serde_json::Value) -> Self {
332        let dat_field = &json["dat"];
333        let pars_field = &dat_field["pars"];
334        ShineMonitorLastData {
335            timestamp: parse_gts(&dat_field["gts"]),
336            grid: ShineMonitorLastDataGrid::from_json(&pars_field["gd_"]),
337            system: ShineMonitorLastDataSystem::from_json(&pars_field["sy_"]),
338            pv: ShineMonitorLastDataPV::from_json(&pars_field["pv_"]),
339            main: ShineMonitorLastDataMain::from_json(&pars_field["bt_"]),
340        }
341    }
342}
343
344/// Parse `gts` which the vendor returns either as
345/// "yyyy-mm-dd HH:MM:SS" or as a milliseconds-since-epoch string.
346fn parse_gts(value: &serde_json::Value) -> NaiveDateTime {
347    if let Some(raw) = value.as_str() {
348        let trimmed = raw.trim();
349        if let Ok(ms) = trimmed.parse::<i64>() {
350            return chrono::DateTime::from_timestamp_millis(ms)
351                .expect("valid epoch ms")
352                .naive_utc();
353        }
354        return NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S")
355            .expect("valid gts string");
356    }
357    if let Some(ms) = value.as_i64() {
358        return chrono::DateTime::from_timestamp_millis(ms)
359            .expect("valid epoch ms")
360            .naive_utc();
361    }
362    panic!("unexpected gts value: {value:?}");
363}
364
365#[derive(Debug, Clone)]
366pub struct ShineMonitorAPI {
367    _base_url: String,
368    _suffix_context: String,
369    _company_key: String,
370    _token: Option<String>,
371    _secret: String,
372    _expire: Option<u64>,
373    _client: Client,
374    _device_params: ShineMonitorDeviceParams,
375}
376
377impl ShineMonitorAPI {
378    pub fn new(serial_number: &str, wifi_pn: &str, dev_code: i32, dev_addr: i32) -> Self {
379        ShineMonitorAPI {
380            _base_url: "http://android.shinemonitor.com/public/".to_string(),
381            _suffix_context: "&i18n=pt_BR&lang=pt_BR&source=1&_app_client_=android&_app_id_=wifiapp.volfw.watchpower&_app_version_=1.0.6.3".to_string(),
382            _company_key: "bnrl_frRFjEz8Mkn".to_string(),
383            _token: None,
384            _secret: "ems_secret".to_string(),
385            _expire: None,
386            _client: Client::new(),
387            _device_params: ShineMonitorDeviceParams {
388                serial_number: serial_number.to_string(),
389                wifi_pn: wifi_pn.to_string(),
390                dev_code,
391                dev_addr,
392            },
393        }
394    }
395
396    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
397        self._base_url = base_url.into();
398        self
399    }
400
401    pub fn with_suffix_context(mut self, suffix: impl Into<String>) -> Self {
402        self._suffix_context = suffix.into();
403        self
404    }
405
406    pub fn with_company_key(mut self, key: impl Into<String>) -> Self {
407        self._company_key = key.into();
408        self
409    }
410
411    fn generate_salt() -> String {
412        let start = SystemTime::now();
413        let since_the_epoch = start
414            .duration_since(UNIX_EPOCH)
415            .expect("Time went backwards");
416        (since_the_epoch.as_millis()).to_string()
417    }
418
419    fn sha1_str_lower_case(input: &[u8]) -> String {
420        let mut hasher = Sha1::new();
421        hasher.update(input);
422        format!("{:x}", hasher.finalize())
423    }
424
425    fn hash(&self, args: Vec<&str>) -> String {
426        let arg_concat = args.join("");
427        ShineMonitorAPI::sha1_str_lower_case(arg_concat.as_bytes())
428    }
429
430    pub fn login(&mut self, username: &str, password: &str) -> Result<(), ApiError> {
431        let base_action = format!(
432            "&action=authSource&usr={}&company-key={}{}",
433            username, self._company_key, self._suffix_context
434        );
435
436        let salt = ShineMonitorAPI::generate_salt();
437        let password_hash = self.hash(vec![password]);
438        let sign = self.hash(vec![&salt, &password_hash, &base_action]);
439
440        let url = format!(
441            "{}?sign={}&salt={}{}",
442            self._base_url, sign, salt, base_action
443        );
444
445        let response: serde_json::Value = self._client.get(&url).send()?.json()?;
446
447        if response["err"].as_i64() == Some(0) {
448            self._secret = response["dat"]["secret"].as_str().unwrap().to_string();
449            self._token = Some(response["dat"]["token"].as_str().unwrap().to_string());
450            self._expire = Some(response["dat"]["expire"].as_u64().unwrap());
451            Ok(())
452        } else {
453            Err(ApiError::from_payload(response))
454        }
455    }
456
457    fn _request(&self, action: &str, query: Option<&str>) -> ShineMonitorAPIResult {
458        let base_action = format!(
459            "&action={}&pn={}&devcode={}&sn={}&devaddr={}{}{}",
460            action,
461            self._device_params.wifi_pn,
462            self._device_params.dev_code,
463            self._device_params.serial_number,
464            self._device_params.dev_addr,
465            query.unwrap_or(""),
466            self._suffix_context
467        );
468        self._request_raw(&base_action)
469    }
470
471    /// Sign and dispatch a request whose `base_action` was assembled by the
472    /// caller. The generated action methods in `actions.rs` use this so they
473    /// don't have to drag the legacy device-params into every call.
474    pub fn _request_with(&self, action: &str, extra: &str) -> ShineMonitorAPIResult {
475        let base_action = format!("&action={}{}{}", action, extra, self._suffix_context);
476        self._request_raw(&base_action)
477    }
478
479    fn _request_raw(&self, base_action: &str) -> ShineMonitorAPIResult {
480        let token = self
481            ._token
482            .as_ref()
483            .ok_or_else(|| ApiError::local(-1, "not logged in"))?;
484        let salt = ShineMonitorAPI::generate_salt();
485        let sign = self.hash(vec![&salt, &self._secret, token, base_action]);
486        let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
487        let url = format!("{}{}{}", self._base_url, auth, base_action);
488
489        let response: serde_json::Value = self._client.get(&url).send()?.json()?;
490
491        if response["err"].as_i64() == Some(0) {
492            Ok(response)
493        } else {
494            Err(ApiError::from_payload(response))
495        }
496    }
497
498    pub fn get_daily_data(&self, day: NaiveDate) -> Result<serde_json::Value, ApiError> {
499        let _date = day.format("%Y-%m-%d").to_string();
500        let query = format!("&date={}", _date);
501        self._request("queryDeviceDataOneDay", Some(&query))
502    }
503
504    pub fn get_last_data(&self) -> Result<ShineMonitorLastData, ApiError> {
505        let raw = self._request("querySPDeviceLastData", None)?;
506        Ok(ShineMonitorLastData::from_json(&raw))
507    }
508}