use chrono::{NaiveDate, NaiveDateTime};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use std::collections::HashSet;
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
mod actions;
pub type ShineMonitorAPIResult = Result<serde_json::Value, ApiError>;
fn auth_err_codes() -> HashSet<i64> {
[0x0007, 0x000F, 0x0010, 0x0019, 0x0105, 0x010E]
.into_iter()
.collect()
}
#[derive(Debug, Clone)]
pub struct ApiError {
pub err: i64,
pub desc: String,
pub payload: serde_json::Value,
}
impl ApiError {
pub fn is_auth(&self) -> bool {
auth_err_codes().contains(&self.err)
}
fn from_payload(payload: serde_json::Value) -> Self {
let err = payload.get("err").and_then(|v| v.as_i64()).unwrap_or(-1);
let desc = payload
.get("desc")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
ApiError { err, desc, payload }
}
fn local(err: i64, desc: impl Into<String>) -> Self {
ApiError {
err,
desc: desc.into(),
payload: serde_json::Value::Null,
}
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"shinemonitor: err=0x{:04X} desc={:?}",
self.err, self.desc
)
}
}
impl std::error::Error for ApiError {}
impl From<reqwest::Error> for ApiError {
fn from(e: reqwest::Error) -> Self {
ApiError::local(-1, format!("transport: {e}"))
}
}
#[derive(Debug, Serialize, Clone)]
struct ShineMonitorDeviceParams {
serial_number: String,
wifi_pn: String,
dev_code: i32,
dev_addr: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceIdentifier {
#[serde(alias = "devalias")]
pub device_alias: Option<String>,
#[serde(alias = "sn")]
pub serial_number: String,
#[serde(alias = "pn")]
pub wifi_pin: String,
#[serde(alias = "devaddr")]
pub device_address: i64,
#[serde(alias = "devcode")]
pub device_code: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppProfile {
pub app_id: &'static str,
pub app_version: &'static str,
pub company_key: &'static str,
pub base_url: &'static str,
pub locale: &'static str,
pub source: i32,
pub app_client: &'static str,
}
impl AppProfile {
pub fn suffix_context(&self) -> String {
format!(
"&i18n={}&lang={}&source={}&_app_client_={}&_app_id_={}&_app_version_={}",
self.locale, self.locale, self.source, self.app_client, self.app_id, self.app_version
)
}
pub const WATCHPOWER: AppProfile = AppProfile {
app_id: "wifiapp.volfw.watchpower",
app_version: "1.0.6.3",
company_key: "bnrl_frRFjEz8Mkn",
base_url: "http://android.shinemonitor.com/public/",
locale: "pt_BR",
source: 1,
app_client: "android",
};
pub const RENOCLIENT: AppProfile = AppProfile {
app_id: "com.eybond.renoclient",
app_version: "1.3.2.0",
company_key: "bnrl_frRFjEz8Mkn",
base_url: "http://android.shinemonitor.com/public/",
locale: "pt_BR",
source: 1,
app_client: "android",
};
}
pub const KNOWN_APPS: &[AppProfile] = &[AppProfile::WATCHPOWER, AppProfile::RENOCLIENT];
#[derive(Debug, Serialize, Clone)]
pub struct ShineMonitorLastDataGrid {
pub grid_rating_voltage: f32,
pub grid_rating_current: f32,
pub battery_rating_voltage: f32,
pub ac_output_rating_voltage: f32,
pub ac_output_rating_current: f32,
pub ac_output_rating_frequency: f32,
pub ac_output_rating_apparent_power: i32,
pub ac_output_rating_active_power: i32,
}
impl ShineMonitorLastDataGrid {
fn from_json(json: &serde_json::Value) -> Self {
let mut grid_rating_voltage = None;
let mut grid_rating_current = None;
let mut battery_rating_voltage = None;
let mut ac_output_rating_voltage = None;
let mut ac_output_rating_current = None;
let mut ac_output_rating_frequency = None;
let mut ac_output_rating_apparent_power = None;
let mut ac_output_rating_active_power = None;
for field in json.as_array().unwrap() {
match field["id"].as_str().unwrap() {
"gd_grid_rating_voltage" => {
grid_rating_voltage =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"gd_grid_rating_current" => {
grid_rating_current =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"gd_battery_rating_voltage" => {
battery_rating_voltage =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"gd_bse_input_voltage_read" => {
ac_output_rating_voltage =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"gd_ac_output_rating_current" => {
ac_output_rating_current =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"gd_bse_output_frequency_read" => {
ac_output_rating_frequency =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"gd_ac_output_rating_apparent_power" => {
ac_output_rating_apparent_power =
Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
}
"gd_ac_output_rating_active_power" => {
ac_output_rating_active_power =
Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
}
_ => continue,
}
}
ShineMonitorLastDataGrid {
grid_rating_voltage: grid_rating_voltage.expect("Grid rating voltage not found"),
grid_rating_current: grid_rating_current.expect("Grid rating current not found"),
battery_rating_voltage: battery_rating_voltage
.expect("Battery rating voltage not found"),
ac_output_rating_voltage: ac_output_rating_voltage
.expect("AC output rating voltage not found"),
ac_output_rating_current: ac_output_rating_current
.expect("AC output rating current not found"),
ac_output_rating_frequency: ac_output_rating_frequency
.expect("AC output rating frequency not found"),
ac_output_rating_apparent_power: ac_output_rating_apparent_power
.expect("AC output rating apparent power not found"),
ac_output_rating_active_power: ac_output_rating_active_power
.expect("AC output rating active power not found"),
}
}
}
#[derive(Debug, Serialize, Clone)]
pub struct ShineMonitorLastDataSystem {
pub model: String,
pub main_cpu_firmware_version: String,
pub secondary_cpu_firmware_version: String,
}
impl ShineMonitorLastDataSystem {
fn from_json(json: &serde_json::Value) -> Self {
let mut model = None;
let mut main_cpu_firmware_version = None;
let mut secondary_cpu_firmware_version = None;
for field in json.as_array().unwrap() {
match field["id"].as_str().unwrap() {
"sy_model" => model = Some(field["val"].as_str().unwrap().to_owned()),
"sy_main_cpu1_firmware_version" => {
main_cpu_firmware_version = Some(field["val"].as_str().unwrap().to_owned())
}
"sy_main_cpu2_firmware_version" => {
secondary_cpu_firmware_version = Some(field["val"].as_str().unwrap().to_owned())
}
_ => continue,
}
}
ShineMonitorLastDataSystem {
model: model.expect("Model not found"),
main_cpu_firmware_version: main_cpu_firmware_version
.expect("Main CPU firmware version not found"),
secondary_cpu_firmware_version: secondary_cpu_firmware_version
.expect("Secondary CPU firmware version not found"),
}
}
}
#[derive(Debug, Serialize, Clone)]
pub struct ShineMonitorLastDataPV {
pub pv_input_current: f32,
}
impl ShineMonitorLastDataPV {
fn from_json(json: &serde_json::Value) -> Self {
let mut pv_input_current = None;
for field in json.as_array().unwrap() {
match field["id"].as_str().unwrap() {
"pv_input_current" => {
pv_input_current = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
_ => continue,
}
}
ShineMonitorLastDataPV {
pv_input_current: pv_input_current.expect("PV input current not found"),
}
}
}
#[derive(Debug, Serialize, Clone)]
pub struct ShineMonitorLastDataMain {
pub grid_voltage: f32,
pub grid_frequency: f32,
pub pv_input_voltage: f32,
pub pv_input_power: i16,
pub battery_voltage: f32,
pub battery_capacity: i8,
pub battery_charging_current: f32,
pub battery_discharge_current: f32,
pub ac_output_voltage: f32,
pub ac_output_frequency: f32,
pub ac_output_apparent_power: i32,
pub ac_output_active_power: i32,
pub output_load_percent: i8,
}
impl ShineMonitorLastDataMain {
fn from_json(json: &serde_json::Value) -> Self {
let mut grid_voltage = None;
let mut grid_frequency = None;
let mut pv_input_voltage = None;
let mut pv_input_power = None;
let mut battery_voltage = None;
let mut battery_capacity = None;
let mut battery_charging_current = None;
let mut battery_discharge_current = None;
let mut ac_output_voltage = None;
let mut ac_output_frequency = None;
let mut ac_output_apparent_power = None;
let mut ac_output_active_power = None;
let mut output_load_percent = None;
for field in json.as_array().unwrap() {
match field["id"].as_str().unwrap() {
"bt_grid_voltage" => {
grid_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_grid_frequency" => {
grid_frequency = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_voltage_1" => {
pv_input_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_input_power" => {
pv_input_power = Some(field["val"].as_str().unwrap().parse::<i16>().unwrap())
}
"bt_battery_voltage" => {
battery_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_battery_capacity" => {
battery_capacity = Some(field["val"].as_str().unwrap().parse::<i8>().unwrap())
}
"bt_battery_charging_current" => {
battery_charging_current =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_battery_discharge_current" => {
battery_discharge_current =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_ac_output_voltage" => {
ac_output_voltage = Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_grid_AC_frequency" => {
ac_output_frequency =
Some(field["val"].as_str().unwrap().parse::<f32>().unwrap())
}
"bt_ac_output_apparent_power" => {
ac_output_apparent_power =
Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
}
"bt_load_active_power_sole" => {
ac_output_active_power =
Some(field["val"].as_str().unwrap().parse::<i32>().unwrap())
}
"bt_output_load_percent" => {
output_load_percent =
Some(field["val"].as_str().unwrap().parse::<i8>().unwrap())
}
_ => continue,
}
}
ShineMonitorLastDataMain {
grid_voltage: grid_voltage.expect("Grid voltage not found"),
grid_frequency: grid_frequency.expect("Grid frequency not found"),
pv_input_voltage: pv_input_voltage.expect("PV input voltage not found"),
pv_input_power: pv_input_power.expect("PV input power not found"),
battery_voltage: battery_voltage.expect("Battery voltage not found"),
battery_capacity: battery_capacity.expect("Battery capacity not found"),
battery_charging_current: battery_charging_current
.expect("Battery charging current not found"),
battery_discharge_current: battery_discharge_current
.expect("Battery discharge current not found"),
ac_output_voltage: ac_output_voltage.expect("AC output voltage not found"),
ac_output_frequency: ac_output_frequency.expect("AC output frequency not found"),
ac_output_apparent_power: ac_output_apparent_power
.expect("AC output apparent power not found"),
ac_output_active_power: ac_output_active_power
.expect("AC output active power not found"),
output_load_percent: output_load_percent.expect("Output load percent not found"),
}
}
}
#[derive(Debug, Serialize, Clone)]
pub struct ShineMonitorLastData {
pub timestamp: NaiveDateTime,
pub grid: ShineMonitorLastDataGrid,
pub system: ShineMonitorLastDataSystem,
pub pv: ShineMonitorLastDataPV,
pub main: ShineMonitorLastDataMain,
}
impl ShineMonitorLastData {
fn from_json(json: &serde_json::Value) -> Self {
let dat_field = &json["dat"];
let pars_field = &dat_field["pars"];
let all_fields = pars_field
.as_object()
.map(|obj| {
obj.values()
.filter(|v| v.is_array())
.flat_map(|v| v.as_array().unwrap().iter().cloned())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let all = serde_json::Value::Array(all_fields);
ShineMonitorLastData {
timestamp: parse_gts(&dat_field["gts"]),
grid: ShineMonitorLastDataGrid::from_json(&all),
system: ShineMonitorLastDataSystem::from_json(&all),
pv: ShineMonitorLastDataPV::from_json(&all),
main: ShineMonitorLastDataMain::from_json(&all),
}
}
}
fn parse_gts(value: &serde_json::Value) -> NaiveDateTime {
if let Some(raw) = value.as_str() {
let trimmed = raw.trim();
if let Ok(ms) = trimmed.parse::<i64>() {
return chrono::DateTime::from_timestamp_millis(ms)
.expect("valid epoch ms")
.naive_utc();
}
return NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S")
.expect("valid gts string");
}
if let Some(ms) = value.as_i64() {
return chrono::DateTime::from_timestamp_millis(ms)
.expect("valid epoch ms")
.naive_utc();
}
panic!("unexpected gts value: {value:?}");
}
#[derive(Debug, Clone)]
pub struct ShineMonitorAPI {
_base_url: String,
_suffix_context: String,
_company_key: String,
_app_profile: AppProfile,
_token: Option<String>,
_secret: String,
_expire: Option<u64>,
_client: Client,
_device_params: ShineMonitorDeviceParams,
}
impl ShineMonitorAPI {
pub fn new(serial_number: &str, wifi_pn: &str, dev_code: i32, dev_addr: i32) -> Self {
ShineMonitorAPI {
_base_url: AppProfile::WATCHPOWER.base_url.to_string(),
_suffix_context: AppProfile::WATCHPOWER.suffix_context(),
_company_key: AppProfile::WATCHPOWER.company_key.to_string(),
_app_profile: AppProfile::WATCHPOWER,
_token: None,
_secret: "ems_secret".to_string(),
_expire: None,
_client: Client::new(),
_device_params: ShineMonitorDeviceParams {
serial_number: serial_number.to_string(),
wifi_pn: wifi_pn.to_string(),
dev_code,
dev_addr,
},
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self._base_url = base_url.into();
self
}
pub fn with_suffix_context(mut self, suffix: impl Into<String>) -> Self {
self._suffix_context = suffix.into();
self
}
pub fn with_company_key(mut self, key: impl Into<String>) -> Self {
self._company_key = key.into();
self
}
pub fn with_app_profile(mut self, profile: AppProfile) -> Self {
self._app_profile = profile.clone();
self._base_url = profile.base_url.to_string();
self._suffix_context = profile.suffix_context();
self._company_key = profile.company_key.to_string();
self
}
fn generate_salt() -> String {
let start = SystemTime::now();
let since_the_epoch = start
.duration_since(UNIX_EPOCH)
.expect("Time went backwards");
(since_the_epoch.as_millis()).to_string()
}
fn sha1_str_lower_case(input: &[u8]) -> String {
let mut hasher = Sha1::new();
hasher.update(input);
format!("{:x}", hasher.finalize())
}
fn hash(&self, args: Vec<&str>) -> String {
let arg_concat = args.join("");
ShineMonitorAPI::sha1_str_lower_case(arg_concat.as_bytes())
}
fn encode_username(username: &str) -> String {
username.replace(' ', "+")
}
pub fn login(&mut self, username: &str, password: &str) -> Result<(), ApiError> {
let base_action = format!(
"&action=authSource&usr={}&company-key={}{}",
ShineMonitorAPI::encode_username(username),
self._company_key,
self._suffix_context
);
let salt = ShineMonitorAPI::generate_salt();
let password_hash = self.hash(vec![password]);
let sign = self.hash(vec![&salt, &password_hash, &base_action]);
let url = format!(
"{}?sign={}&salt={}{}",
self._base_url, sign, salt, base_action
);
let response: serde_json::Value = self._client.get(&url).send()?.json()?;
if response["err"].as_i64() == Some(0) {
self._secret = response["dat"]["secret"].as_str().unwrap().to_string();
self._token = Some(response["dat"]["token"].as_str().unwrap().to_string());
self._expire = Some(response["dat"]["expire"].as_u64().unwrap());
Ok(())
} else {
Err(ApiError::from_payload(response))
}
}
fn _request(&self, action: &str, query: Option<&str>) -> ShineMonitorAPIResult {
let base_action = format!(
"&action={}&pn={}&devcode={}&sn={}&devaddr={}{}{}",
action,
self._device_params.wifi_pn,
self._device_params.dev_code,
self._device_params.serial_number,
self._device_params.dev_addr,
query.unwrap_or(""),
self._suffix_context
);
self._request_raw(&base_action)
}
pub fn _request_with(&self, action: &str, extra: &str) -> ShineMonitorAPIResult {
let base_action = format!("&action={}{}{}", action, extra, self._suffix_context);
self._request_raw(&base_action)
}
fn _request_raw(&self, base_action: &str) -> ShineMonitorAPIResult {
let token = self
._token
.as_ref()
.ok_or_else(|| ApiError::local(-1, "not logged in"))?;
let salt = ShineMonitorAPI::generate_salt();
let sign = self.hash(vec![&salt, &self._secret, token, base_action]);
let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
let url = format!("{}{}{}", self._base_url, auth, base_action);
let response: serde_json::Value = self._client.get(&url).send()?.json()?;
if response["err"].as_i64() == Some(0) {
Ok(response)
} else {
Err(ApiError::from_payload(response))
}
}
pub fn get_daily_data(&self, day: NaiveDate) -> Result<serde_json::Value, ApiError> {
let _date = day.format("%Y-%m-%d").to_string();
let query = format!("&date={}", _date);
self._request("queryDeviceDataOneDay", Some(&query))
}
pub fn get_last_data(&self) -> Result<ShineMonitorLastData, ApiError> {
let raw = self._request("querySPDeviceLastData", None)?;
Ok(ShineMonitorLastData::from_json(&raw))
}
pub fn get_devices(&self) -> Result<Vec<DeviceIdentifier>, ApiError> {
let token = self
._token
.as_ref()
.ok_or_else(|| ApiError::local(-1, "not logged in"))?;
let mut last_err: Option<ApiError> = None;
let current_id = self._app_profile.app_id;
let mut profiles: Vec<&AppProfile> = vec![&self._app_profile];
for known in KNOWN_APPS {
if known.app_id != current_id {
profiles.push(known);
}
}
for profile in profiles {
let suffix = profile.suffix_context();
let base_action = format!("&action=webQueryDeviceEs{}", suffix);
let salt = ShineMonitorAPI::generate_salt();
let sign = self.hash(vec![&salt, &self._secret, token, &base_action]);
let auth = format!("?sign={}&salt={}&token={}", sign, salt, token);
let url = format!("{}{}{}", self._base_url, auth, base_action);
let response: serde_json::Value = match self._client.get(&url).send() {
Ok(r) => match r.json() {
Ok(v) => v,
Err(e) => return Err(ApiError::from(e)),
},
Err(e) => return Err(ApiError::from(e)),
};
if response["err"].as_i64() == Some(0) {
let devices: Vec<DeviceIdentifier> = response["dat"]["device"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect()
})
.unwrap_or_default();
return Ok(devices);
}
let api_err = ApiError::from_payload(response);
if api_err.err != 0x0102 {
return Err(api_err);
}
last_err = Some(api_err);
}
Err(last_err.unwrap_or_else(|| {
ApiError::local(258, "all app profiles returned ERR_NOT_FOUND_DEVICE")
}))
}
}
#[cfg(test)]
mod tests {
use super::ShineMonitorLastData;
#[test]
fn extracts_telemetry_across_separate_block_arrays() {
let json = serde_json::json!({
"err": 0,
"desc": "ERR_NONE",
"dat": {
"gts": "2026-08-05 14:37:29",
"pars": {
"gd_": [
{"id": "gd_grid_rating_voltage", "val": "230.0"},
{"id": "gd_grid_rating_current", "val": "30.0"},
{"id": "gd_battery_rating_voltage", "val": "48.0"},
{"id": "gd_bse_input_voltage_read", "val": "230.0"},
{"id": "gd_ac_output_rating_current", "val": "30.0"},
{"id": "gd_bse_output_frequency_read", "val": "50.0"},
{"id": "gd_ac_output_rating_apparent_power", "val": "5000"},
{"id": "gd_ac_output_rating_active_power", "val": "5000"},
],
"sy_": [
{"id": "sy_model", "val": "Off Grid"},
{"id": "sy_main_cpu1_firmware_version", "val": "01.23"},
{"id": "sy_main_cpu2_firmware_version", "val": "04.56"},
],
"pv_": [
{"id": "pv_input_current", "val": "12.5"},
{"id": "bt_voltage_1", "val": "129.4"},
{"id": "bt_input_power", "val": "0"},
],
"bt_": [
{"id": "bt_grid_voltage", "val": "0.0"},
{"id": "bt_grid_frequency", "val": "50.0"},
{"id": "bt_battery_voltage", "val": "49.3"},
{"id": "bt_battery_capacity", "val": "71"},
{"id": "bt_battery_charging_current", "val": "0.0"},
{"id": "bt_battery_discharge_current", "val": "0.0"},
{"id": "bt_ac_output_apparent_power", "val": "1200"},
],
"bc_": [
{"id": "bt_ac_output_voltage", "val": "230.0"},
{"id": "bt_grid_AC_frequency", "val": "50.0"},
{"id": "bt_load_active_power_sole", "val": "533"},
{"id": "bt_output_load_percent", "val": "12"},
],
},
},
});
let snapshot = ShineMonitorLastData::from_json(&json);
assert!((snapshot.main.pv_input_voltage - 129.4).abs() < 0.001);
assert_eq!(snapshot.main.pv_input_power, 0);
assert_eq!(snapshot.main.battery_capacity, 71);
assert!((snapshot.main.ac_output_voltage - 230.0).abs() < 0.001);
assert!((snapshot.main.ac_output_frequency - 50.0).abs() < 0.001);
assert_eq!(snapshot.main.ac_output_active_power, 533);
assert_eq!(snapshot.main.output_load_percent, 12);
}
}