sungrow_winets/
lib.rs

1use serde::Deserialize;
2use serde_aux::prelude::*;
3use std::time::Duration;
4use thiserror::Error;
5use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
6use tracing::{debug, error};
7
8#[derive(Error, Debug)]
9#[non_exhaustive]
10pub enum Error {
11    #[error(transparent)]
12    WebsocketErr(#[from] tungstenite::error::Error),
13
14    #[error(transparent)]
15    HttpErr(#[from] reqwest::Error),
16
17    #[error(transparent)]
18    HttpHdrErr(#[from] reqwest::header::InvalidHeaderValue),
19
20    // Thank you stranger https://github.com/dtolnay/thiserror/pull/175
21    #[error("{code}{}", match .message {
22        Some(msg) => format!(" - {}", &msg),
23        None => "".to_owned(),
24    })]
25    SungrowError { code: u16, message: Option<String> },
26
27    #[error(transparent)]
28    JSONError(#[from] serde_json::Error),
29
30    #[error("Expected attached data")]
31    ExpectedData,
32
33    #[error("No token")]
34    NoToken,
35}
36
37impl From<Error> for std::io::Error {
38    fn from(e: Error) -> Self {
39        use std::io::ErrorKind;
40        // TODO: Likely there are reasonable mappings from some of our errors to specific io Errors but, for now, this
41        // is just so tokio_modbus-winets can fail conveniently.
42        std::io::Error::new(ErrorKind::Other, e)
43    }
44}
45
46#[derive(Debug)]
47pub struct Client {
48    http: reqwest::Client,
49    host: String,
50    username: String,
51    password: String,
52    token: Option<String>,
53    devices: Vec<Device>,
54}
55
56pub struct ClientBuilder {
57    host: String,
58    username: String,
59    password: String,
60    token: Option<String>,
61    user_agent: String,
62
63    read_timeout: Option<Duration>,
64    connect_timeout: Option<Duration>,
65    timeout: Option<Duration>,
66}
67
68// These are Sungrow's default passwords on the WiNet-S, but they are user-changeable
69static DEFAULT_USERNAME: &str = "admin";
70static DEFAULT_PASSWORD: &str = "pw8888";
71static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
72
73impl ClientBuilder {
74    pub fn new(host: String) -> Self {
75        Self {
76            host: host.into(),
77            username: DEFAULT_USERNAME.into(),
78            password: DEFAULT_PASSWORD.into(),
79            user_agent: DEFAULT_USER_AGENT.into(),
80            token: None,
81
82            connect_timeout: Some(Duration::from_secs(1)),
83            read_timeout: Some(Duration::from_secs(1)),
84            timeout: Some(Duration::from_secs(1)),
85        }
86    }
87
88    pub fn build(self) -> Result<Client> {
89        use reqwest::header;
90
91        let mut headers = reqwest::header::HeaderMap::new();
92        headers.insert(header::ACCEPT, "application/json".parse()?);
93        headers.insert(header::CONNECTION, "keep-alive".parse()?);
94
95        let mut http_builder = reqwest::ClientBuilder::new()
96            .user_agent(header::HeaderValue::from_str(&self.user_agent)?)
97            .default_headers(headers)
98            .pool_max_idle_per_host(1)
99            .redirect(reqwest::redirect::Policy::none())
100            .referer(false);
101
102        if let Some(timeout) = self.timeout {
103            http_builder = http_builder.timeout(timeout);
104        }
105        if let Some(timeout) = self.connect_timeout {
106            http_builder = http_builder.connect_timeout(timeout);
107        }
108        if let Some(timeout) = self.read_timeout {
109            http_builder = http_builder.read_timeout(timeout);
110        }
111
112        let http = http_builder.build()?;
113
114        Ok(Client {
115            host: self.host,
116            username: self.username.into(),
117            password: self.password.into(),
118            token: self.token.into(),
119            devices: vec![],
120            http,
121        })
122    }
123
124    pub fn username(mut self, username: String) -> Self {
125        self.username = username;
126        self
127    }
128
129    pub fn password(mut self, password: String) -> Self {
130        self.password = password;
131        self
132    }
133
134    pub fn token(mut self, token: String) -> Self {
135        self.token = Some(token);
136        self
137    }
138
139    pub fn user_agent(mut self, user_agent: String) -> Self {
140        self.user_agent = user_agent.into();
141        self
142    }
143
144    pub fn read_timeout(mut self, timeout: Option<Duration>) -> Self {
145        self.read_timeout = timeout;
146        self
147    }
148
149    pub fn connect_timeout(mut self, timeout: Option<Duration>) -> Self {
150        self.connect_timeout = timeout;
151        self
152    }
153
154    pub fn timeout(mut self, timeout: Option<Duration>) -> Self {
155        self.timeout = timeout;
156        self
157    }
158}
159
160type Result<T> = std::result::Result<T, Error>;
161
162impl Client {
163    const WS_PORT: u16 = 8082;
164
165    async fn token(&mut self) -> Result<String> {
166        if self.token.is_none() {
167            self.token = Some(self.get_token().await?);
168        }
169
170        Ok(self.token.as_ref().expect("no token").clone())
171    }
172
173    async fn get_token(&self) -> Result<String> {
174        use futures_util::SinkExt;
175        use futures_util::StreamExt;
176        use serde_json::json;
177
178        let ws_url = format!("ws://{}:{}/ws/home/overview", &self.host, Self::WS_PORT);
179        debug!(%ws_url, "Connecting to WiNet-S websocket");
180
181        let (mut ws, _) = connect_async(ws_url).await?;
182
183        let connect = Message::Text(
184            json!({
185                "lang": "en_us",
186                "token": self.token.as_deref().unwrap_or_default(),
187                "service": "connect"
188            })
189            .to_string(),
190        );
191        debug!(%connect, "Sending connect message");
192        ws.send(connect).await?;
193
194        while let Some(Ok(Message::Text(msg))) = ws.next().await {
195            debug!(%msg, "Got WS message");
196
197            if let SungrowResult {
198                code: 1,
199                data: Some(ResultData::WebSocketMessage(msg)),
200                ..
201            } = serde_json::from_str(&msg)?
202            {
203                match msg {
204                    WebSocketMessage::Connect { token: Some(token) } => {
205                        let login = Message::Text(
206                            json!({
207                                "lang": "en_us",
208                                "token": token,
209                                "service": "login",
210                                "username": &self.username,
211                                "passwd": &self.password,
212                            })
213                            .to_string(),
214                        );
215                        debug!(%login, "Sending login message");
216                        ws.send(login).await?;
217                    }
218                    WebSocketMessage::Login { token } => {
219                        return Ok(token.expect(
220                            "Login message should have a token, if not an error response",
221                        ));
222                    }
223                    message => {
224                        debug!(?message, "Got other message");
225                    }
226                }
227            }
228        }
229
230        Err(Error::NoToken)
231    }
232
233    pub async fn connect(&mut self) -> Result<()> {
234        let data: ResultData = parse_response(
235            self.http
236                .post(format!("http://{}/inverter/list", &self.host))
237                .send()
238                .await?,
239        )
240        .await?;
241
242        if let ResultData::DeviceList(ResultList { items, .. }) = data {
243            self.devices = items;
244        } else {
245            return Err(Error::ExpectedData);
246        }
247
248        self.token = Some(self.get_token().await?);
249        Ok(())
250    }
251
252    // #[tracing::instrument(level = "debug")]
253    pub async fn read_register(
254        &mut self,
255        register_type: RegisterType,
256        address: u16,
257        count: u16,
258    ) -> Result<Vec<u16>> {
259        let token = self.token().await?;
260
261        // FIXME: find device by phys_addr
262        let device = &self.devices[0];
263
264        let request = self
265            .http
266            .get(format!("http://{}{}", &self.host, "/device/getParam"))
267            .header(reqwest::header::ACCEPT, "application/json")
268            .query(&[
269                ("dev_id", device.dev_id),
270                ("dev_type", device.dev_type),
271                ("param_type", register_type.param()),
272                ("type", 3),
273            ])
274            .query(&[
275                ("dev_code", device.dev_code),
276                ("param_addr", address),
277                ("param_num", count),
278            ])
279            .query(&[("lang", "en_us"), ("token", &token)]);
280        debug!(?request, "sending request");
281        let response = request.send().await?;
282
283        let result = parse_response(response).await?;
284
285        if let ResultData::GetParam { param_value } = result {
286            Ok(param_value)
287        } else {
288            Err(Error::ExpectedData)
289        }
290    }
291
292    #[tracing::instrument(level = "debug")]
293    pub async fn write_register(&mut self, address: u16, data: &[u16]) -> Result<()> {
294        if data.is_empty() {
295            return Err(Error::ExpectedData);
296        }
297        // FIXME: find device by phys_addr
298        let device = &self.devices[0];
299
300        use serde_json::json;
301        let body = json!({
302            "dev_id": device.dev_id,
303            "dev_type": device.dev_type,
304            "dev_code": device.dev_code,
305            "param_addr": address.to_string(),
306            "param_size": data.len().to_string(),
307            "param_value": data[0].to_string(),
308            "lang": "en_us",
309            "token": &self.token().await?,
310        });
311        let request = self
312            .http
313            .post(format!("http://{}{}", &self.host, "/device/setParam"))
314            .header(reqwest::header::ACCEPT, "application/json")
315            .json(&body);
316        let response = request.send().await?;
317        parse_response(response).await?;
318        Ok(())
319    }
320
321    pub async fn running_state(&mut self) -> Result<RunningState> {
322        let raw = *self
323            .read_register(RegisterType::Input, 13001, 1)
324            .await?
325            .first()
326            .ok_or(Error::ExpectedData)?;
327        let bits: RunningStateBits = raw.into();
328
329        let battery_state = if bits.intersects(RunningStateBits::BatteryCharging) {
330            BatteryState::Charging
331        } else if bits.intersects(RunningStateBits::BatteryDischarging) {
332            BatteryState::Discharging
333        } else {
334            BatteryState::Inactive
335        };
336
337        let trading_state = if bits.intersects(RunningStateBits::ImportingPower) {
338            TradingState::Importing
339        } else if bits.intersects(RunningStateBits::ExportingPower) {
340            TradingState::Exporting
341        } else {
342            TradingState::Inactive
343        };
344
345        Ok(RunningState {
346            battery_state,
347            trading_state,
348            generating_pv_power: bits.intersects(RunningStateBits::GeneratingPVPower),
349            positive_load_power: bits.intersects(RunningStateBits::LoadActive),
350            power_generated_from_load: bits.intersects(RunningStateBits::GeneratingPVPower),
351            state: bits,
352        })
353    }
354}
355
356#[derive(Debug)]
357pub enum BatteryState {
358    Charging,
359    Discharging,
360    Inactive,
361}
362
363#[derive(Debug)]
364pub enum TradingState {
365    Importing,
366    Exporting,
367    Inactive,
368}
369
370#[derive(Debug)]
371pub struct RunningState {
372    state: RunningStateBits,
373    pub battery_state: BatteryState,
374    pub trading_state: TradingState,
375    pub generating_pv_power: bool,
376    pub positive_load_power: bool,
377    pub power_generated_from_load: bool,
378}
379
380impl RunningState {
381    pub fn raw(&self) -> RunningStateBits {
382        self.state
383    }
384}
385
386// See Appendix 1.2 of Sungrow modbus documentation for hybrid inverters
387#[bitmask_enum::bitmask(u16)]
388pub enum RunningStateBits {
389    GeneratingPVPower = 0b00000001,
390    BatteryCharging = 0b00000010,
391    BatteryDischarging = 0b00000100,
392    LoadActive = 0b00001000,
393    LoadReactive = 0b00000000,
394    ExportingPower = 0b00010000,
395    ImportingPower = 0b00100000,
396    PowerGeneratedFromLoad = 0b0100000,
397}
398
399#[tracing::instrument(level = "debug")]
400async fn parse_response<T>(response: reqwest::Response) -> Result<T>
401where
402    Result<T>: From<SungrowResult>,
403{
404    let body = response.text().await?;
405    debug!(%body, "parsing");
406    let sg_result = serde_json::from_slice::<SungrowResult>(body.as_bytes());
407    sg_result?.into()
408}
409
410#[derive(Debug)]
411pub enum RegisterType {
412    Input,
413    Holding,
414}
415
416impl RegisterType {
417    fn param(&self) -> u8 {
418        match self {
419            Self::Input => 0,
420            Self::Holding => 1,
421        }
422    }
423}
424
425// {
426// 		"id":	1,
427// 		"dev_id":	1,
428// 		"dev_code":	3343,
429// 		"dev_type":	35,
430// 		"dev_procotol":	2,
431// 		"inv_type":	0,
432// 		"dev_sn":	"REDACTED",
433// 		"dev_name":	"SH5.0RS(COM1-001)",
434// 		"dev_model":	"SH5.0RS",
435// 		"port_name":	"COM1",
436// 		"phys_addr":	"1",
437// 		"logc_addr":	"1",
438// 		"link_status":	1,
439// 		"init_status":	1,
440// 		"dev_special":	"0",
441// 		"list":	[]
442// }
443#[derive(Clone, Debug, Deserialize)]
444struct Device {
445    dev_id: u8,
446    dev_code: u16,
447
448    // Available from `GET /device/getType`:
449    //
450    // {
451    //     "result_code":  1,
452    //     "result_msg":   "success",
453    //     "result_data":  {
454    //             "count":        5,
455    //             "list": [{
456    //                             "name": "I18N_COMMON_STRING_INVERTER",
457    //                             "value":        1
458    //                     }, {
459    //                             "name": "I18N_COMMON_SOLAR_INVERTER",
460    //                             "value":        21
461    //                     }, {
462    //                             "name": "I18N_COMMON_STORE_INVERTER",
463    //                             "value":        35
464    //                     }, {
465    //                             "name": "I18N_COMMON_AMMETER",
466    //                             "value":        18
467    //                     }, {
468    //                             "name": "I18N_COMMON_CHARGING_PILE",
469    //                             "value":        46
470    //                     }]
471    //     }
472    // }
473    //
474    // TODO: Extract into enum represented by underlying number?
475    dev_type: u8,
476
477    // unit/slave ID
478    #[allow(dead_code)]
479    #[serde(deserialize_with = "serde_aux::prelude::deserialize_number_from_string")]
480    phys_addr: u8,
481    // UNUSED:
482    //
483    // id: u8,
484    // dev_protocol: u8,
485    // dev_sn: String,
486    // dev_model: String,
487    // port_name: String,
488    // logc_address: String,
489    // link_status: u8,
490    // init_status: u8,
491    // dev_special: String,
492    // list: Option<Vec<()>> // unknown
493}
494
495#[test]
496fn test_deserialize_device() {
497    let json = r#"{
498        "id":	1,
499        "dev_id":	1,
500        "dev_code":	3343,
501        "dev_type":	35,
502        "dev_procotol":	2,
503        "inv_type":	0,
504        "dev_sn":	"REDACTED",
505        "dev_name":	"SH5.0RS(COM1-001)",
506        "dev_model":	"SH5.0RS",
507        "port_name":	"COM1",
508        "phys_addr":	"1",
509        "logc_addr":	"1",
510        "link_status":	1,
511        "init_status":	1,
512        "dev_special":	"0"
513    }"#;
514
515    let dev: Device = serde_json::from_str(json).unwrap();
516
517    assert!(matches!(
518        dev,
519        Device {
520            dev_id: 1,
521            dev_code: 3343,
522            dev_type: 35,
523            phys_addr: 1
524        }
525    ));
526}
527#[derive(Clone, Debug, Deserialize)]
528#[serde(tag = "service", rename_all = "lowercase")]
529enum WebSocketMessage {
530    Connect {
531        token: Option<String>,
532        // uid: u8,
533        // tips_disable: u8,
534        // virgin_flag: u8,
535        // isFirstLogin: u8,
536        // forceModifyPasswd: u8,
537    },
538    Login {
539        token: Option<String>,
540    },
541
542    // DeviceList { list: Vec<Device> },
543
544    // Not yet used:
545    // State,  // system state
546    // Real,   // real time info
547    // Notice, // on some error messages?
548    // Statistics,
549    // Runtime,
550    // Local,
551    // Fault,
552    // #[serde(rename = "proto_modbus104")]
553    // Modbus,
554    Other,
555}
556
557#[derive(Clone, Debug, Deserialize)]
558struct ResultList<T> {
559    // count: u16,
560    #[serde(rename = "list")]
561    items: Vec<T>,
562}
563
564#[derive(Clone, Debug, Deserialize)]
565#[serde(untagged)]
566enum ResultData {
567    // TODO: custom deserializer into words
568    GetParam {
569        #[serde(deserialize_with = "words_from_string")]
570        param_value: Vec<u16>,
571    },
572    DeviceList(ResultList<Device>),
573    WebSocketMessage(WebSocketMessage),
574
575    // // String = name  - http://<host>/i18n/en_US.properties has the translations for these item names
576    // // i32 = value    - unclear if this is always an int, so making this a JSON::Value for now
577    // GetType(ResultList<(String, serde_json::Value)>),
578    // Product {
579    //     #[serde(rename = "product_name")]
580    //     name: String,
581    //     #[serde(rename = "product_code")]
582    //     code: u8,
583    // },
584    Other,
585}
586
587#[test]
588fn test_deserialize_get_param() {
589    let json = r#"{"param_value":  "82 00 "}"#;
590    let data: ResultData = serde_json::from_str(json).unwrap();
591    assert!(matches!(data, ResultData::GetParam { .. }));
592
593    let json = r#"{
594        "result_code":  1,
595        "result_msg":   "success",
596        "result_data":  {
597                "param_value":  "82 00 "
598        }
599    }"#;
600
601    let data: SungrowResult = serde_json::from_str(json).unwrap();
602    assert!(matches!(
603        data,
604        SungrowResult {
605            code: 1,
606            message: Some(m),
607            data: Some(ResultData::GetParam { .. })
608        } if m == "success"
609    ));
610}
611
612// TODO: can I make this an _actual_ `Result<ResultData, SungrowError>`?
613//         - if code == 1, it is Ok(SungrowData), otherwise create error from code and message?
614#[derive(Clone, Debug, Deserialize)]
615struct SungrowResult {
616    // 1 = success
617    // 100 = hit user limit?
618    //      {
619    //      	"result_code":	100,
620    //      	"result_msg":	"normal user limit",
621    //      	"result_data":	{
622    //      		"service":	"notice"
623    //      	}
624    //      }
625    #[serde(rename = "result_code")]
626    code: u16,
627
628    #[serde(rename = "result_msg")]
629    // http://<host>/i18n/en_US.properties has the translations for messages (only ones which start with I18N_*)
630    message: Option<String>, // at least one result I saw (code = 200 at the time) had no message :\
631
632    #[serde(rename = "result_data")]
633    data: Option<ResultData>,
634}
635
636impl From<SungrowResult> for Result<Option<ResultData>> {
637    fn from(sg_result: SungrowResult) -> Self {
638        match sg_result {
639            SungrowResult { code: 1, data, .. } => Ok(data),
640            SungrowResult { code, message, .. } => Err(Error::SungrowError { code, message }),
641        }
642    }
643}
644impl From<SungrowResult> for Result<ResultData> {
645    fn from(sg_result: SungrowResult) -> Self {
646        let data: Result<Option<ResultData>> = sg_result.into();
647
648        if let Some(data) = data? {
649            Ok(data)
650        } else {
651            Err(Error::ExpectedData)
652        }
653    }
654}
655impl From<SungrowResult> for Result<()> {
656    fn from(sg_result: SungrowResult) -> Self {
657        let data: Result<Option<ResultData>> = sg_result.into();
658        data.map(|_| ())
659    }
660}
661
662// WiNet-S returns data encoded as space-separated hex byte string. E.g.:
663//
664//      "aa bb cc dd " (yes, including trailing whitespace)
665//
666// Modbus uses u16 "words" instead of bytes, and the data above should always represent this, so we can take groups
667// of 2 and consume them as a hex-represented u16.
668fn words_from_string<'de, D>(deserializer: D) -> std::result::Result<Vec<u16>, D::Error>
669where
670    D: serde::Deserializer<'de>,
671{
672    StringOrVecToVec::new(' ', |s| u8::from_str_radix(s, 16), true).into_deserializer()(
673        deserializer,
674    )
675    .map(|vec| {
676        vec.chunks_exact(2)
677            .map(|bytes| u16::from_be_bytes(bytes.try_into().unwrap()))
678            .collect()
679    })
680}
681
682#[test]
683fn test_words_from_string() {
684    #[derive(serde::Deserialize, Debug)]
685    struct MyStruct {
686        #[serde(deserialize_with = "words_from_string")]
687        list: Vec<u16>,
688    }
689
690    let s = r#" { "list": "00 AA 00 01 00 0D 00 1E 00 0F 00 00 00 55 " } "#;
691    let a: MyStruct = serde_json::from_str(s).unwrap();
692    assert_eq!(
693        &a.list,
694        &[0x00AA, 0x0001, 0x000D, 0x001E, 0x000F, 0x0000, 0x0055]
695    );
696}