Skip to main content

shinemonitor_api/
lib.rs

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
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/// Device as returned by the device list endpoints (webQueryDeviceEs /
82/// queryDevices). Fields use the same serde aliases as the Python
83/// `DeviceIdentifier` model for wire compatibility.
84#[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/// White-label vendor app identity. The ShineMonitor backend is
99/// multi-tenant: devices are scoped to the `_app_id_` they were
100/// registered under. Two presets (`WATCHPOWER`, `RENOCLIENT`) are
101/// shipped; construct a custom one for other Eybond white-labels.
102#[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    /// Build the query-string suffix this app injects into every request.
115    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    /// WatchPower (wifiapp.volfw.watchpower) — the default profile.
123    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    /// RenoClient / Renovigi (com.eybond.renoclient).
134    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
145/// All known vendor app profiles. The [`crate::ShineMonitorAPI::get_devices`]
146/// fallback iterates this list when the primary profile returns
147/// `ERR_NOT_FOUND_DEVICE` (err 258 / 0x0102).
148pub 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        ShineMonitorLastData {
404            timestamp: parse_gts(&dat_field["gts"]),
405            grid: ShineMonitorLastDataGrid::from_json(&pars_field["gd_"]),
406            system: ShineMonitorLastDataSystem::from_json(&pars_field["sy_"]),
407            pv: ShineMonitorLastDataPV::from_json(&pars_field["pv_"]),
408            main: ShineMonitorLastDataMain::from_json(&pars_field["bt_"]),
409        }
410    }
411}
412
413/// Parse `gts` which the vendor returns either as
414/// "yyyy-mm-dd HH:MM:SS" or as a milliseconds-since-epoch string.
415fn parse_gts(value: &serde_json::Value) -> NaiveDateTime {
416    if let Some(raw) = value.as_str() {
417        let trimmed = raw.trim();
418        if let Ok(ms) = trimmed.parse::<i64>() {
419            return chrono::DateTime::from_timestamp_millis(ms)
420                .expect("valid epoch ms")
421                .naive_utc();
422        }
423        return NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S")
424            .expect("valid gts string");
425    }
426    if let Some(ms) = value.as_i64() {
427        return chrono::DateTime::from_timestamp_millis(ms)
428            .expect("valid epoch ms")
429            .naive_utc();
430    }
431    panic!("unexpected gts value: {value:?}");
432}
433
434#[derive(Debug, Clone)]
435pub struct ShineMonitorAPI {
436    _base_url: String,
437    _suffix_context: String,
438    _company_key: String,
439    _app_profile: AppProfile,
440    _token: Option<String>,
441    _secret: String,
442    _expire: Option<u64>,
443    _client: Client,
444    _device_params: ShineMonitorDeviceParams,
445}
446
447impl ShineMonitorAPI {
448    pub fn new(serial_number: &str, wifi_pn: &str, dev_code: i32, dev_addr: i32) -> Self {
449        ShineMonitorAPI {
450            _base_url: AppProfile::WATCHPOWER.base_url.to_string(),
451            _suffix_context: AppProfile::WATCHPOWER.suffix_context(),
452            _company_key: AppProfile::WATCHPOWER.company_key.to_string(),
453            _app_profile: AppProfile::WATCHPOWER,
454            _token: None,
455            _secret: "ems_secret".to_string(),
456            _expire: None,
457            _client: Client::new(),
458            _device_params: ShineMonitorDeviceParams {
459                serial_number: serial_number.to_string(),
460                wifi_pn: wifi_pn.to_string(),
461                dev_code,
462                dev_addr,
463            },
464        }
465    }
466
467    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
468        self._base_url = base_url.into();
469        self
470    }
471
472    pub fn with_suffix_context(mut self, suffix: impl Into<String>) -> Self {
473        self._suffix_context = suffix.into();
474        self
475    }
476
477    pub fn with_company_key(mut self, key: impl Into<String>) -> Self {
478        self._company_key = key.into();
479        self
480    }
481
482    /// Override the app profile and its associated fields
483    /// (`_base_url`, `_suffix_context`, `_company_key`).
484    pub fn with_app_profile(mut self, profile: AppProfile) -> Self {
485        self._app_profile = profile.clone();
486        self._base_url = profile.base_url.to_string();
487        self._suffix_context = profile.suffix_context();
488        self._company_key = profile.company_key.to_string();
489        self
490    }
491
492    fn generate_salt() -> String {
493        let start = SystemTime::now();
494        let since_the_epoch = start
495            .duration_since(UNIX_EPOCH)
496            .expect("Time went backwards");
497        (since_the_epoch.as_millis()).to_string()
498    }
499
500    fn sha1_str_lower_case(input: &[u8]) -> String {
501        let mut hasher = Sha1::new();
502        hasher.update(input);
503        format!("{:x}", hasher.finalize())
504    }
505
506    fn hash(&self, args: Vec<&str>) -> String {
507        let arg_concat = args.join("");
508        ShineMonitorAPI::sha1_str_lower_case(arg_concat.as_bytes())
509    }
510
511    pub fn login(&mut self, username: &str, password: &str) -> Result<(), ApiError> {
512        let base_action = format!(
513            "&action=authSource&usr={}&company-key={}{}",
514            username, self._company_key, self._suffix_context
515        );
516
517        let salt = ShineMonitorAPI::generate_salt();
518        let password_hash = self.hash(vec![password]);
519        let sign = self.hash(vec![&salt, &password_hash, &base_action]);
520
521        let url = format!(
522            "{}?sign={}&salt={}{}",
523            self._base_url, sign, salt, base_action
524        );
525
526        let response: serde_json::Value = self._client.get(&url).send()?.json()?;
527
528        if response["err"].as_i64() == Some(0) {
529            self._secret = response["dat"]["secret"].as_str().unwrap().to_string();
530            self._token = Some(response["dat"]["token"].as_str().unwrap().to_string());
531            self._expire = Some(response["dat"]["expire"].as_u64().unwrap());
532            Ok(())
533        } else {
534            Err(ApiError::from_payload(response))
535        }
536    }
537
538    fn _request(&self, action: &str, query: Option<&str>) -> ShineMonitorAPIResult {
539        let base_action = format!(
540            "&action={}&pn={}&devcode={}&sn={}&devaddr={}{}{}",
541            action,
542            self._device_params.wifi_pn,
543            self._device_params.dev_code,
544            self._device_params.serial_number,
545            self._device_params.dev_addr,
546            query.unwrap_or(""),
547            self._suffix_context
548        );
549        self._request_raw(&base_action)
550    }
551
552    /// Sign and dispatch a request whose `base_action` was assembled by the
553    /// caller. The generated action methods in `actions.rs` use this so they
554    /// don't have to drag the legacy device-params into every call.
555    pub fn _request_with(&self, action: &str, extra: &str) -> ShineMonitorAPIResult {
556        let base_action = format!("&action={}{}{}", action, extra, self._suffix_context);
557        self._request_raw(&base_action)
558    }
559
560    fn _request_raw(&self, base_action: &str) -> ShineMonitorAPIResult {
561        let token = self
562            ._token
563            .as_ref()
564            .ok_or_else(|| ApiError::local(-1, "not logged in"))?;
565        let salt = ShineMonitorAPI::generate_salt();
566        let sign = self.hash(vec![&salt, &self._secret, token, base_action]);
567        let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
568        let url = format!("{}{}{}", self._base_url, auth, base_action);
569
570        let response: serde_json::Value = self._client.get(&url).send()?.json()?;
571
572        if response["err"].as_i64() == Some(0) {
573            Ok(response)
574        } else {
575            Err(ApiError::from_payload(response))
576        }
577    }
578
579    pub fn get_daily_data(&self, day: NaiveDate) -> Result<serde_json::Value, ApiError> {
580        let _date = day.format("%Y-%m-%d").to_string();
581        let query = format!("&date={}", _date);
582        self._request("queryDeviceDataOneDay", Some(&query))
583    }
584
585    pub fn get_last_data(&self) -> Result<ShineMonitorLastData, ApiError> {
586        let raw = self._request("querySPDeviceLastData", None)?;
587        Ok(ShineMonitorLastData::from_json(&raw))
588    }
589
590    /// Enumerate devices across all known app profiles.
591    ///
592    /// First tries the configured profile's `webQueryDeviceEs`; on
593    /// `ERR_NOT_FOUND_DEVICE` (err 258 = 0x0102) it retries each known
594    /// profile. Non-258 errors propagate immediately.
595    pub fn get_devices(&self) -> Result<Vec<DeviceIdentifier>, ApiError> {
596        let token = self
597            ._token
598            .as_ref()
599            .ok_or_else(|| ApiError::local(-1, "not logged in"))?;
600        let mut last_err: Option<ApiError> = None;
601
602        let current_id = self._app_profile.app_id;
603        let mut profiles: Vec<&AppProfile> = vec![&self._app_profile];
604        for known in KNOWN_APPS {
605            if known.app_id != current_id {
606                profiles.push(known);
607            }
608        }
609
610        for profile in profiles {
611            let suffix = profile.suffix_context();
612            let base_action = format!("&action=webQueryDeviceEs{}", suffix);
613            let salt = ShineMonitorAPI::generate_salt();
614            let sign = self.hash(vec![&salt, &self._secret, token, &base_action]);
615            let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
616            let url = format!("{}{}{}", self._base_url, auth, base_action);
617
618            let response: serde_json::Value = match self._client.get(&url).send() {
619                Ok(r) => match r.json() {
620                    Ok(v) => v,
621                    Err(e) => return Err(ApiError::from(e)),
622                },
623                Err(e) => return Err(ApiError::from(e)),
624            };
625
626            if response["err"].as_i64() == Some(0) {
627                let devices: Vec<DeviceIdentifier> = response["dat"]["device"]
628                    .as_array()
629                    .map(|arr| {
630                        arr.iter()
631                            .filter_map(|v| serde_json::from_value(v.clone()).ok())
632                            .collect()
633                    })
634                    .unwrap_or_default();
635                return Ok(devices);
636            }
637
638            let api_err = ApiError::from_payload(response);
639            if api_err.err != 0x0102 {
640                return Err(api_err);
641            }
642            last_err = Some(api_err);
643        }
644
645        Err(last_err.unwrap_or_else(|| {
646            ApiError::local(258, "all app profiles returned ERR_NOT_FOUND_DEVICE")
647        }))
648    }
649}