From e3d4d024ce39bdd13fd573da7312650d7e570f0c Mon Sep 17 00:00:00 2001 From: Bo Jeanes Date: Tue, 30 Aug 2022 16:28:33 +1000 Subject: [PATCH] Implement basic WiNet-S modbus driver This also splits the project into 3 crates, which are _theoretically_ indepently useful, though the target audience will be very small... --- Cargo.lock | 119 +- Cargo.toml | 36 +- README.md | 65 +- modbus-mqtt/Cargo.toml | 27 + modbus-mqtt/src/connection.rs | 4 + {src => modbus-mqtt/src}/main.rs | 7 +- {src => modbus-mqtt/src}/modbus/config.rs | 2 +- {src => modbus-mqtt/src}/modbus/mod.rs | 1 - modbus-mqtt/tests/integration_test.rs | 0 src/modbus/sungrow.rs | 137 -- sungrow-winets/Cargo.toml | 30 + sungrow-winets/NOTES.md | 1858 +++++++++++++++++++ sungrow-winets/README.md | 9 + sungrow-winets/examples/poll.rs | 21 + sungrow-winets/examples/set_forced_power.rs | 43 + sungrow-winets/src/lib.rs | 590 ++++++ tokio_modbus-winets/Cargo.toml | 11 + tokio_modbus-winets/src/client.rs | 20 + tokio_modbus-winets/src/lib.rs | 4 + tokio_modbus-winets/src/service.rs | 99 + 20 files changed, 2888 insertions(+), 195 deletions(-) create mode 100644 modbus-mqtt/Cargo.toml create mode 100644 modbus-mqtt/src/connection.rs rename {src => modbus-mqtt/src}/main.rs (98%) rename {src => modbus-mqtt/src}/modbus/config.rs (99%) rename {src => modbus-mqtt/src}/modbus/mod.rs (99%) create mode 100644 modbus-mqtt/tests/integration_test.rs delete mode 100644 src/modbus/sungrow.rs create mode 100644 sungrow-winets/Cargo.toml create mode 100644 sungrow-winets/NOTES.md create mode 100644 sungrow-winets/README.md create mode 100644 sungrow-winets/examples/poll.rs create mode 100644 sungrow-winets/examples/set_forced_power.rs create mode 100644 sungrow-winets/src/lib.rs create mode 100644 tokio_modbus-winets/Cargo.toml create mode 100644 tokio_modbus-winets/src/client.rs create mode 100644 tokio_modbus-winets/src/lib.rs create mode 100644 tokio_modbus-winets/src/service.rs diff --git a/Cargo.lock b/Cargo.lock index 61e2e6c..4ae58f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -87,6 +96,16 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitmask-enum" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76487de46597d345d040a1be49a6fb636b71d0abab4696b7f3492e0cd4639c73" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "block-buffer" version = "0.10.2" @@ -126,6 +145,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +dependencies = [ + "iana-time-zone", + "num-integer", + "num-traits", + "winapi", +] + [[package]] name = "clap" version = "3.2.17" @@ -497,16 +528,16 @@ dependencies = [ ] [[package]] -name = "hyper-rustls" -version = "0.23.0" +name = "iana-time-zone" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" +checksum = "ad2bfd338099682614d3ee3fe0cd72e0b6a41ca6a87f6a74a3bd593c91650501" dependencies = [ - "http", - "hyper", - "rustls", - "tokio", - "tokio-rustls", + "android_system_properties", + "core-foundation-sys", + "js-sys", + "wasm-bindgen", + "winapi", ] [[package]] @@ -520,6 +551,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + [[package]] name = "indexmap" version = "1.9.1" @@ -685,14 +722,11 @@ dependencies = [ name = "modbus-mqtt" version = "0.1.0" dependencies = [ - "async-trait", "bytes", "clap", - "futures-util", "humantime-serde", "itertools", "pretty_assertions", - "reqwest", "rumqttc", "rust_decimal", "serde", @@ -701,7 +735,7 @@ dependencies = [ "tokio", "tokio-modbus", "tokio-serial", - "tokio-tungstenite", + "tokio_modbus-winets", "tracing", "tracing-subscriber", "uuid", @@ -740,6 +774,16 @@ dependencies = [ "libc", ] +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -958,7 +1002,6 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-rustls", "ipnet", "js-sys", "lazy_static", @@ -966,14 +1009,10 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-native-certs", - "rustls-pemfile 1.0.1", "serde", "serde_json", "serde_urlencoded", "tokio", - "tokio-rustls", "tower-service", "url", "wasm-bindgen", @@ -1133,6 +1172,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-aux" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a77223b653fa95f3f9864f3eb25b93e4ed170687eb42d85b6b98af21d5e1de" +dependencies = [ + "chrono", + "serde", + "serde_json", +] + [[package]] name = "serde_derive" version = "1.0.144" @@ -1251,6 +1301,25 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "sungrow-winets" +version = "0.1.0" +dependencies = [ + "bitmask-enum", + "futures-util", + "if_chain", + "reqwest", + "serde", + "serde-aux", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "tungstenite", +] + [[package]] name = "syn" version = "1.0.99" @@ -1400,12 +1469,8 @@ checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" dependencies = [ "futures-util", "log", - "rustls", - "rustls-native-certs", "tokio", - "tokio-rustls", "tungstenite", - "webpki", ] [[package]] @@ -1422,6 +1487,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "tokio_modbus-winets" +version = "0.1.0" +dependencies = [ + "async-trait", + "sungrow-winets", + "tokio-modbus", + "tracing", +] + [[package]] name = "tower-service" version = "0.3.2" @@ -1505,12 +1580,10 @@ dependencies = [ "httparse", "log", "rand", - "rustls", "sha-1", "thiserror", "url", "utf-8", - "webpki", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8794dac..8223976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,6 @@ -[package] -name = "modbus-mqtt" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -async-trait = "0.1.57" -bytes = "1.1.0" -clap = { version = "3.2.12", features = ["derive", "env"] } -futures-util = "0.3.23" -humantime-serde = "1.1.1" -itertools = "0.10.3" -reqwest = { version = "0.11.11", features = ["rustls-tls-native-roots", "json"], default-features = false } -rumqttc = "0.15.0" -rust_decimal = { version = "1.26.1", features = ["serde-arbitrary-precision", "serde-float", "serde_json", "maths"] } -serde = { version = "1.0.139", features = ["serde_derive"] } -serde_json = "1.0.82" -serialport = { version = "4.2.0", features = ["serde"] } -tokio = { version = "1.20.0", features = ["rt", "rt-multi-thread", "time"] } -tokio-modbus = "0.5.3" -tokio-serial = "5.4.3" -tokio-tungstenite = { version = "0.17.2", features = ["rustls-tls-native-roots"] } -tracing = "0.1.36" -tracing-subscriber = "0.3.15" -uuid = { version = "1.1.2", features = ["v4", "serde"] } - -[dev-dependencies] -pretty_assertions = "1.2.1" +[workspace] +members = [ + "modbus-mqtt", + "sungrow-winets", + "tokio_modbus-winets", +] diff --git a/README.md b/README.md index 191e878..a96663f 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,67 @@ prefix/connection//monitor[/opt-name] <- { ## Similar projects * https://github.com/Instathings/modbus2mqtt -* https://github.com/TenySmart/ModbusTCP2MQTT - Sungrow inverter specific \ No newline at end of file +* https://github.com/TenySmart/ModbusTCP2MQTT - Sungrow inverter specific + +## Example connect config + +```json +{ + "host": "10.10.10.219", + "unit": 1, + "proto": "tcp", + "address_offset": -1, + "input": [{ + "address": 5017, + "type": "u32", + "name": "dc_power", + "swap_words": false, + "period": "3s" + }, + { + "address": 5008, + "type": "s16", + "name": "internal_temperature", + "period": "1m" + }, + { + "address": 13008, + "type": "s32", + "name": "load_power", + "swap_words": false, + "period": "3s" + }, + { + "address": 13010, + "type": "s32", + "name": "export_power", + "swap_words": false, + "period": "3s" + }, + { + "address": 13022, + "name": "battery_power", + "period": "3s" + }, + { + "address": 13023, + "name": "battery_level", + "period": "1m" + }, + { + "address": 13024, + "name": "battery_health", + "period": "10m" + }], + "hold": [{ + "address": 13058, + "name": "max_soc", + "period": "90s" + }, + { + "address": 13059, + "name": "min_soc", + "period": "90s" + }] +} +``` \ No newline at end of file diff --git a/modbus-mqtt/Cargo.toml b/modbus-mqtt/Cargo.toml new file mode 100644 index 0000000..5045411 --- /dev/null +++ b/modbus-mqtt/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "modbus-mqtt" +version = "0.1.0" +edition = "2021" +authors = ["Bo Jeanes "] +default-run = "modbus-mqtt" + +[dependencies] +bytes = "1.1.0" +clap = { version = "3.2.12", features = ["derive", "env"] } +humantime-serde = "1.1.1" +itertools = "0.10.3" +rumqttc = "0.15.0" +rust_decimal = { version = "1.26.1", features = ["serde-arbitrary-precision", "serde-float", "serde_json", "maths"] } +serde = { version = "1.0.139", features = ["serde_derive"] } +serde_json = "1.0.82" +serialport = { version = "4.2.0", features = ["serde"] } +tokio = { version = "1.20.0", features = ["rt", "rt-multi-thread", "time"] } +tokio-modbus = "0.5.3" +tokio-serial = "5.4.3" +tokio_modbus-winets = { path = "../tokio_modbus-winets" } +tracing = "0.1.36" +tracing-subscriber = "0.3.15" +uuid = { version = "1.1.2", features = ["v4", "serde"] } + +[dev-dependencies] +pretty_assertions = "1.2.1" diff --git a/modbus-mqtt/src/connection.rs b/modbus-mqtt/src/connection.rs new file mode 100644 index 0000000..bd10053 --- /dev/null +++ b/modbus-mqtt/src/connection.rs @@ -0,0 +1,4 @@ +pub struct Connection { + // connect: Connect, + context: tokio_modbus::client::Context, +} diff --git a/src/main.rs b/modbus-mqtt/src/main.rs similarity index 98% rename from src/main.rs rename to modbus-mqtt/src/main.rs index 5c1a610..c4ad5ad 100644 --- a/src/main.rs +++ b/modbus-mqtt/src/main.rs @@ -2,7 +2,7 @@ use rumqttc::{self, AsyncClient, Event, Incoming, LastWill, MqttOptions, Publish use serde::Serialize; use serde_json::json; use std::{collections::HashMap, time::Duration}; -use tokio::{select, sync::mpsc, sync::oneshot, time::MissedTickBehavior}; +use tokio::{sync::mpsc, sync::oneshot, time::MissedTickBehavior}; use tokio_modbus::prelude::*; use tracing::{debug, error, info}; @@ -77,6 +77,7 @@ async fn main() { enum DispatchCommand { Publish { topic: String, payload: Vec }, } +#[tracing::instrument(level = "debug")] async fn mqtt_dispatcher( mut options: MqttOptions, prefix: String, @@ -179,6 +180,7 @@ enum RegistryCommand { type RegistryDb = HashMap>; +#[tracing::instrument(level = "debug")] async fn connection_registry( prefix: String, dispatcher: mpsc::Sender, @@ -244,7 +246,7 @@ async fn handle_connect( let mut modbus = match connect.settings { ModbusProto::SungrowWiNetS { ref host } => { - modbus::sungrow::winets::connect_slave(host, unit) + tokio_modbus_winets::connect_slave(host, unit) .await .unwrap() } @@ -359,6 +361,7 @@ async fn handle_connect( } } +#[tracing::instrument(level = "debug")] async fn watch_registers( read_type: ModbusReadType, address_offset: i8, diff --git a/src/modbus/config.rs b/modbus-mqtt/src/modbus/config.rs similarity index 99% rename from src/modbus/config.rs rename to modbus-mqtt/src/modbus/config.rs index febaa5e..77d3f2c 100644 --- a/src/modbus/config.rs +++ b/modbus-mqtt/src/modbus/config.rs @@ -208,7 +208,7 @@ pub struct RegisterParse { pub value_type: RegisterValueType, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Register { pub address: u16, diff --git a/src/modbus/mod.rs b/modbus-mqtt/src/modbus/mod.rs similarity index 99% rename from src/modbus/mod.rs rename to modbus-mqtt/src/modbus/mod.rs index 5c2c28a..131b568 100644 --- a/src/modbus/mod.rs +++ b/modbus-mqtt/src/modbus/mod.rs @@ -4,7 +4,6 @@ use serde::Serialize; use self::config::{Register, RegisterValueType}; pub mod config; -pub mod sungrow; #[derive(Serialize)] #[serde(rename_all = "lowercase")] diff --git a/modbus-mqtt/tests/integration_test.rs b/modbus-mqtt/tests/integration_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/modbus/sungrow.rs b/src/modbus/sungrow.rs deleted file mode 100644 index f3b66a5..0000000 --- a/src/modbus/sungrow.rs +++ /dev/null @@ -1,137 +0,0 @@ -pub mod winets { - use async_trait::async_trait; - use std::io::Error; - use tokio::time::MissedTickBehavior; - use tokio_modbus::client::Client; - use tokio_modbus::client::Context as ModbusContext; - use tokio_modbus::prelude::{Request, Response}; - use tokio_modbus::slave::{Slave, SlaveContext}; - - use tracing::{debug, error, info}; - - pub async fn connect(host: H) -> Result - where - H: Into, - { - connect_slave(host, Slave(1)).await - } - - pub async fn connect_slave(host: H, slave: Slave) -> Result - where - H: Into, - { - let (tx, mut rx) = tokio::sync::watch::channel(None); - - tokio::spawn(async move { - debug!("Starting WiNet-S websocket"); - use futures_util::SinkExt; - // use futures_util::{future, pin_mut, StreamExt}; - use futures_util::StreamExt; - use std::time::Duration; - // use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use serde_json::Value as JSON; - use tokio::select; - use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; - - let ws_url = format!("ws://{}:8082/ws/home/overview", "10.10.10.219"); - let (mut ws_stream, _) = connect_async(ws_url).await.expect("Failed to connect"); - // let (write, read) = ws_stream.split(); - ws_stream - .send(Message::Text( - serde_json::json!({"lang":"en_us","token":"","service":"connect"}).to_string(), - )) - .await - .expect("whoops"); - - // WiNet-S interface sends following message every now and then: - // {"lang":"zh_cn","service":"ping","token":"","id":"84c2265b-5f7f-4915-82e9-57250064316f"} - // UUID is always random, token always seems blank. - // Unclear if this is a real `Ping` message or just a regular `Text` message with "ping" content. - // update: it is just a text message 🙄 - // Response is just: - // { "result_code": 1, "result_msg": "success" } - let mut ping = tokio::time::interval(Duration::from_secs(5)); - ping.set_missed_tick_behavior(MissedTickBehavior::Delay); - - loop { - select! { - Some(resp) = ws_stream.next() => { - match resp { - Ok(msg) => { - debug!(%msg, "WS ->"); - - if let Message::Text(msg) = msg { - let value: JSON = serde_json::from_str(&msg).expect("expected json"); - if let JSON::String(ref token) = value["result_data"]["token"] { - // FIXME: this should fails when all receivers have been dropped but I'm pretty - // sure rx is not dropped because it's moved into Context struct :/ - tx.send(Some(token.clone())).unwrap(); - } - } - }, - Err(err) => error!(?err, "WS ->") - } - }, - _ = ping.tick() => { - let msg = serde_json::json!({ - "lang":"en_us", // WiNet-S always sends zh_cn, but this works - "service":"ping", - // WiNet-S includes `"token": ""`, but it works without it - "id": uuid::Uuid::new_v4() - }).to_string(); - debug!(%msg, "WS <-"); - ws_stream - .send(Message::Text(msg)) - .await - .expect("whoops"); - } - } - } - }); - - // wait for a token before returning the client, so that it is ready - rx.changed().await; - - let box_: Box = Box::new(Context { - unit: Some(slave), - token: rx, - }); - Ok(ModbusContext::from(box_)) - } - - /// Equivalent to tokio_modbus::service::tcp::Context - #[derive(Debug)] - pub struct Context { - unit: Option, - token: tokio::sync::watch::Receiver>, - // TODO: websocket + keep TCP connection for HTTP? - } - - #[async_trait] - impl Client for Context { - #[tracing::instrument(level = "debug")] - async fn call(&mut self, request: Request) -> Result { - match request { - Request::ReadCoils(_, _) => todo!(), - Request::ReadDiscreteInputs(_, _) => todo!(), - Request::WriteSingleCoil(_, _) => todo!(), - Request::WriteMultipleCoils(_, _) => todo!(), - Request::ReadInputRegisters(_, _) => { - Result::Ok(Response::ReadInputRegisters(vec![0xaa])) - } - Request::ReadHoldingRegisters(_, _) => todo!(), - Request::WriteSingleRegister(_, _) => todo!(), - Request::WriteMultipleRegisters(_, _) => todo!(), - Request::ReadWriteMultipleRegisters(_, _, _, _) => todo!(), - Request::Custom(_, _) => todo!(), - Request::Disconnect => todo!(), - } - } - } - - impl SlaveContext for Context { - fn set_slave(&mut self, slave: tokio_modbus::slave::Slave) { - self.unit = Some(slave); - } - } -} diff --git a/sungrow-winets/Cargo.toml b/sungrow-winets/Cargo.toml new file mode 100644 index 0000000..f7277da --- /dev/null +++ b/sungrow-winets/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "sungrow-winets" +version = "0.1.0" +edition = "2021" +authors = ["Bo Jeanes "] + +[dependencies] +bitmask-enum = "2.0.0" +futures-util = "0.3.23" +if_chain = "1.0.2" +reqwest = { version = "0.11.11", features = ["json"], default-features = false } +serde = { version = "1.0.139", features = ["serde_derive"] } +serde-aux = "3.1.0" +serde_json = "1.0.82" +thiserror = "1.0.32" +tokio = { version = "1.20.0", features = ["time"] } +tokio-tungstenite = { version = "0.17.2" } +tracing = "0.1.36" +tungstenite = "0.17.3" + +[dev-dependencies] +tracing-subscriber = "0.3.15" + +[[example]] +name = "poll" +required-features = ["tokio/rt", "tokio/macros"] + +[[example]] +name = "set_forced_power" +required-features = ["tokio/rt", "tokio/macros"] diff --git a/sungrow-winets/NOTES.md b/sungrow-winets/NOTES.md new file mode 100644 index 0000000..acd153a --- /dev/null +++ b/sungrow-winets/NOTES.md @@ -0,0 +1,1858 @@ +# Requests + +Of note: + +* The responses are pretty similar between websocket requests and HTTP requests +* `result_code`: + * 1 - success + * 106 - invalid or expired token + * 200 - ? + * 301 - message: "I18N_COMMON_READ_FAILED" - seem to be fleeting and also corresponds with HTTP request error `requestError { ..., source: hyper::Error(IncompleteMessage) }` which seems to imply the connection was closed abruptly by the server. + * 391 - I18N_COMMON_SET_FAILED + + * varying other values for different types of errors +* The `result_msg` is a usually "success", but contains more detail for certain errors. In at least one observed error + response, the key was missing entirely. + +## HTTP + +The requests in the Web UI often have other parameters, including the token included. But if they are omitted below, +it's because they were not found to be necessary. + +### Key translations + +```sh-session +❯ curl http://$INVERTER_IP/i18n/en_US.properties +I18N_COMMON_SENIOR_SET_TEN_ENABLE=10 Min Over Vtg En. +I18N_COMMON_AB_VOLTAGE=A-B Line Voltage +I18N_CONFIG_KEY_796=AFCI Self Inspection Failure +I18N_COMMON_A_PHARE_POWER=Phase A Active Power +I18N_COMMON_BC_VOLTAGE=B-C Line Voltage +I18N_CONFIG_KEY_854=Bin Document CRC Checkout Error +I18N_COMMON_B_PHARE_POWER=Phase B Active Power +I18N_COMMON_CA_VOLTAGE=C-A Line Voltage +I18N_COMMON_C_PHARE_POWER=Phase C Active Power +... +``` + +### About + +```sh-session +❯ curl http://$INVERTER_IP/about/list +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "list": [{ + "data_name": "I18N_COMMON_DEVICE_SN", + "data_value": "REDACTED", + "data_unit": "", + "type": "1" + }, { + "data_name": "I18N_COMMON_APPLI_SOFT_VERSION", + "data_value": "WINET-SV200.001.00.P012", + "data_unit": "", + "type": "2" + }, { + "data_name": "I18N_COMMON_BUILD_SOFT_VERSION", + "data_value": "WINET-SV200.001.00.B001", + "data_unit": "", + "type": "2" + }, { + "data_name": "I18N_COMMON_VERSION", + "data_value": "M_WiNet-S_V01_V01_A", + "data_unit": "", + "type": "0" + }] + } +} +``` + +### Device Types + +This seems to return the values used in `dev_id` field for devices from list + +```sh-session +❯ curl http://$INVERTER_IP/device/getType +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "count": 5, + "list": [{ + "name": "I18N_COMMON_STRING_INVERTER", + "value": 1 + }, { + "name": "I18N_COMMON_SOLAR_INVERTER", + "value": 21 + }, { + "name": "I18N_COMMON_STORE_INVERTER", + "value": 35 + }, { + "name": "I18N_COMMON_AMMETER", + "value": 18 + }, { + "name": "I18N_COMMON_CHARGING_PILE", + "value": 46 + }] + } +} +``` + +### Product List + +### Device List + +```sh-session +❯ curl http://$INVERTER_IP/inverter/list -X POST +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "list": [{ + "id": 1, + "dev_id": 1, + "dev_code": 3343, + "dev_type": 35, + "dev_procotol": 2, + "inv_type": 0, + "dev_sn": "REDACTED", + "dev_name": "SH5.0RS(COM1-001)", + "dev_model": "SH5.0RS", + "port_name": "COM1", + "phys_addr": "1", + "logc_addr": "1", + "link_status": 1, + "init_status": 1, + "dev_special": "0" + }, { + "id": 2, + "dev_id": 2, + "dev_code": 8424, + "dev_type": 44, + "dev_procotol": 0, + "inv_type": 0, + "dev_sn": "REDACTED", + "dev_name": "SBR128(COM1-200)", + "dev_model": "SBR128", + "port_name": "COM1", + "phys_addr": "200", + "logc_addr": "2", + "link_status": 1, + "init_status": 255, + "dev_special": "0" + }], + "count": 2 + } +} +``` + +See also device listing over websocket. + +### Time + +Weirdly, this needs to be authenticated of all things. + +```sh-session +❯ curl http://$INVERTER_IP/time/get?token=$TOKEN +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "time": "2022-08-29 20:14", + "sync_device": "0", + "dispatching_mode": "0", + "ntp_server_jp": "re-ene.kyuden.co.jp", + "curr_timezone": "UTC+10:00", + "source": "7", + "ntp_server": "au.pool.ntp.org", + "ntp_port": "123", + "ntp_interval": "5", + "ntp_timestamp": "2022-08-29 20:12:44", + "tz_reboot_flag": "0", + "data_name": "I18N_COMMON_LONGITUDE", + "data_value": "--", + "data_unit": "", + "data_name": "I18N_COMMON_LATITUDE", + "data_value": "--", + "data_unit": "", + "timezone_gps": "UTC" + } +} +``` + +### Overview + +```sh-session +❯ curl http://$INVERTER_IP/device/overview?token=fd919fa6-6ff4-46ac-90c5-6d367edc84ad +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "module_info": { + "module_sn": "REDACTED", + "module_ver": "M_WiNet-S_V01_V01_A" + }, + "net_info": { + "wifi_conn_sts": 0, + "eth_conn_sts": 1, + "eth2_conn_sts": 0, + "wifi_cmd": 170 + }, + "remote_info": { + "module_sn": "REDACTED", + "ip": "app.isolarcloud.com" + }, + "sys_time": { + "sync_device": 0, + "time": "2022-08-29 20:19", + "timezone": "UTC+10:00" + }, + "list": [{ + "dev_name": "SH5.0RS(COM1-001)", + "dev_sn": "REDACTED", + "link_status": 1, + "country_code": 6, + "country": "I18N_COMMON_AUSTRALIA", + "company": "AS/NZS 4777.2:2020 Australia A", + "company_code": "13" + }] + } +} +``` + +### Get Initial Parameters + +Used to generate a Word Doc report, based on the template at `/template.docx`. + +```sh-session +❯ curl http://$INVERTER_IP/device/getInitParam?token=$TOKEN +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "list": [{ + "dev_name": "SH5.0RS(COM1-001)", + "dev_sn": "REDACTED", + "list": [{ + "param_addr": 31605, + "param_name": "I18N_COMMON_REACTIVE_REGULATION_MODE", + "param_value": "164", + "unit": "", + "value_name": "Q(U)" + }, { + "param_addr": 31700, + "param_name": "I18N_COMMON_Q_U_CURVE", + "param_value": "0", + "unit": "", + "value_name": "I18N_COMMON_A_CURVE" + }, { + "param_addr": 31712, + "param_name": "QU_EnableMode", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_YES" + }, { + "param_addr": 32578, + "param_name": "I18N_10RT_RNNN_1527766", + "param_value": "162", + "unit": "", + "value_name": "I18N_COMMON_MAXIMUM_POWER" + }, { + "param_addr": 30092, + "param_name": "I18N_COMMON_FAULT_RECOVERY_TIME", + "param_value": "60", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31400, + "param_name": "I18N_COMMON_FREQUENCY_DROP_STATUS", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 31404, + "param_name": "F1", + "param_value": "50.25", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31405, + "param_name": "F2", + "param_value": "50.75", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31406, + "param_name": "F3", + "param_value": "52.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31409, + "param_name": "P1", + "param_value": "200.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31410, + "param_name": "P2", + "param_value": "100.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31411, + "param_name": "P3", + "param_value": "0.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31412, + "param_name": "I18N_COMMON_OVER_FREQUENCY_DROP_RECOVERY_POINT", + "param_value": "50.15", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31413, + "param_name": "I18N_COMMON_OVER_FREQUENCY_DROP_CURVE", + "param_value": "1", + "unit": "", + "value_name": "I18N_COMMON_B_CURVE" + }, { + "param_addr": 31414, + "param_name": "I18N_COMMON_OVER_FREQUENCY_DROP_ACTIVE_RATE", + "param_value": "6000", + "unit": "%/min", + "value_name": "" + }, { + "param_addr": 31415, + "param_name": "I18N_COMMON_OVER_FREQUENCY_DROP_WAIT_RESTORE_TIME", + "param_value": "20.0", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31416, + "param_name": "I18N_COMMON_OVER_FREQUENCY_DROP_ACTIVE_RESTORE_RATE", + "param_value": "16", + "unit": "%/min", + "value_name": "" + }, { + "param_addr": 31417, + "param_name": "I18N_COMMON_OVER_FREQUENCY_DROP_RESPONSE_TIME", + "param_value": "0.00", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31420, + "param_name": "I18N_COMMON_FRE_INCREMENT", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 31421, + "param_name": "F1", + "param_value": "49.75", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31422, + "param_name": "F2", + "param_value": "49.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31423, + "param_name": "F3", + "param_value": "48.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31427, + "param_name": "P1", + "param_value": "0.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31428, + "param_name": "P2", + "param_value": "100.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31429, + "param_name": "P3", + "param_value": "200.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31433, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_UP_RESTORE_POINT", + "param_value": "49.85", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 31434, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_UP_CURVE", + "param_value": "1", + "unit": "", + "value_name": "I18N_COMMON_B_CURVE" + }, { + "param_addr": 31435, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_UP_ACTIVE_RATE", + "param_value": "6000", + "unit": "%/min", + "value_name": "" + }, { + "param_addr": 31436, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_UP_WAIT_RESTORE_TIME", + "param_value": "20.0", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31437, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_UP_ACTIVE_RESTORE_RATE", + "param_value": "16", + "unit": "%/min", + "value_name": "" + }, { + "param_addr": 31438, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_UP_RESPONSE_TIME", + "param_value": "0.00", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31196, + "param_name": "I18N_COMMON_FAULT_ACTIVE_SLOWDOWN", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 31197, + "param_name": "I18N_COMMON_FAULT_ACTIVE_SLOWDOWN_TIME", + "param_value": "360", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31200, + "param_name": "I18N_COMMON_ACTIVE_SPEED_CONTROL", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 31201, + "param_name": "I18N_COMMON_ACTIVE_REACTIVE_DOWN", + "param_value": "16", + "unit": "%/min", + "value_name": "" + }, { + "param_addr": 31202, + "param_name": "I18N_COMMON_ACTIVE_REACTIVE_UP", + "param_value": "16", + "unit": "%/min", + "value_name": "" + }, { + "param_addr": 31230, + "param_name": "I18N_COMMON_GRID_VOLTAGE_ACTIVE_ADJUST", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 31231, + "param_name": "OPU_V1", + "param_value": "253.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31232, + "param_name": "OPU_V2", + "param_value": "260.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31233, + "param_name": "OPU_V3", + "param_value": "260.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31234, + "param_name": "OPU_V4", + "param_value": "260.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31235, + "param_name": "OPU_P1", + "param_value": "100.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31236, + "param_name": "OPU_P2", + "param_value": "20.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31237, + "param_name": "OPU_P3", + "param_value": "20.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31238, + "param_name": "OPU_P4", + "param_value": "20.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31239, + "param_name": "I18N_CONFIG_KEY_1002331", + "param_value": "1.0", + "unit": "s", + "value_name": "" + }, { + "param_addr": 33006, + "param_name": "I18N_COMMON_GRID_VOLTAGE_CHARGE_REGULATION", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 33007, + "param_name": "UPU_V1", + "param_value": "215.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 33008, + "param_name": "UPU_V2", + "param_value": "207.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 33009, + "param_name": "UPU_V3", + "param_value": "207.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 33010, + "param_name": "UPU_V4", + "param_value": "207.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 33011, + "param_name": "UPU_P1", + "param_value": "0.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 33012, + "param_name": "UPU_P2", + "param_value": "80.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 33013, + "param_name": "UPU_P3", + "param_value": "80.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 33014, + "param_name": "UPU_P4", + "param_value": "80.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 33015, + "param_name": "I18N_CONFIG_KEY_1002461", + "param_value": "1.0", + "unit": "s", + "value_name": "" + }, { + "param_addr": 31615, + "param_name": "I18N_COMMON_REACTIVE_RESPONSE", + "param_value": "85", + "unit": "", + "value_name": "I18N_COMMON_CLOSE" + }, { + "param_addr": 31865, + "param_name": "QU_V1(AU)", + "param_value": "207.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31866, + "param_name": "QU_V2(AU)", + "param_value": "220.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31867, + "param_name": "QU_V3(AU)", + "param_value": "240.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31868, + "param_name": "QU_V4(AU)", + "param_value": "258.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31869, + "param_name": "QU_Q1(AU)", + "param_value": "-44.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31870, + "param_name": "QU_Q2(AU)", + "param_value": "0.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31871, + "param_name": "QU_Q3(AU)", + "param_value": "0.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 31872, + "param_name": "QU_Q4(AU)", + "param_value": "60.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 30295, + "param_name": "I18N_COMMON_SENIOR_SET_TEN", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 30296, + "param_name": "I18N_CONFIG_KEY_1001984", + "param_value": "258.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 30297, + "param_name": "I18N_COMMON_10_V_REVERT", + "param_value": "256.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 30800, + "param_name": "I18N_CONFIG_KEY_1001963", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 30801, + "param_name": "I18N_CONFIG_KEY_1001964", + "param_value": "85", + "unit": "", + "value_name": "I18N_COMMON_CLOSE" + }, { + "param_addr": 30799, + "param_name": "I18N_CONFIG_KEY_1001962", + "param_value": "85", + "unit": "", + "value_name": "I18N_COMMON_CLOSE" + }, { + "param_addr": 30798, + "param_name": "I18N_COMMON_LVRT_PROTECTION_SERIES", + "param_value": "2", + "unit": "", + "value_name": "2" + }, { + "param_addr": 30803, + "param_name": "I18N_COMMON_LVRT_VOLTAGE_PH%@1", + "param_value": "180.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 30804, + "param_name": "I18N_COMMON_LVRT_VOLTAGE_PH%@2", + "param_value": "70.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 30813, + "param_name": "I18N_COMMON_LVRT_TIME_PH%@1", + "param_value": "10000", + "unit": "ms", + "value_name": "" + }, { + "param_addr": 30815, + "param_name": "I18N_COMMON_LVRT_TIME_PH%@2", + "param_value": "1000", + "unit": "ms", + "value_name": "" + }, { + "param_addr": 30999, + "param_name": "I18N_CONFIG_KEY_1001971", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 31000, + "param_name": "I18N_CONFIG_KEY_1044", + "param_value": "85", + "unit": "", + "value_name": "I18N_COMMON_CLOSE" + }, { + "param_addr": 30998, + "param_name": "I18N_CONFIG_KEY_1001970", + "param_value": "85", + "unit": "", + "value_name": "I18N_COMMON_CLOSE" + }, { + "param_addr": 30997, + "param_name": "I18N_COMMON_HVRT_PROTECTION_SERIES", + "param_value": "1", + "unit": "", + "value_name": "1" + }, { + "param_addr": 31001, + "param_name": "I18N_COMMON_HVRT_VOLTAGE_PH%@1", + "param_value": "260.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 31012, + "param_name": "I18N_COMMON_HVRT_TIME_PH%@1", + "param_value": "1000", + "unit": "ms", + "value_name": "" + }, { + "param_addr": 32313, + "param_name": "I18N_COMMON_PROTECTION_SERIES", + "param_value": "1", + "unit": "", + "value_name": "2" + }, { + "param_addr": 32322, + "param_name": "I18N_COMMON_UNDER_VOLTAGE_LEVEL_VALUE_PH%@1", + "param_value": "180.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 32323, + "param_name": "I18N_COMMON_OVER_VOLTAGE_LEVEL_VALUE_PH%@1", + "param_value": "260.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 32324, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_LEVEL_VALUE_PH%@1", + "param_value": "47.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32325, + "param_name": "I18N_COMMON_OVER_FREQUENCY_LEVEL_VALUE_PH%@1", + "param_value": "52.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32362, + "param_name": "I18N_COMMON_UNDER_VOLTAGE_LEVEL_TIME_PH%@1", + "param_value": "10.50", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32364, + "param_name": "I18N_COMMON_OVER_VOLTAGE_LEVEL_TIME_PH%@1", + "param_value": "1.50", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32366, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_LEVEL_TIME_PH%@1", + "param_value": "1.50", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32368, + "param_name": "I18N_COMMON_OVER_FREQUENCY_LEVEL_TIME_PH%@1", + "param_value": "0.10", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32326, + "param_name": "I18N_COMMON_UNDER_VOLTAGE_LEVEL_VALUE_PH%@2", + "param_value": "180.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 32327, + "param_name": "I18N_COMMON_OVER_VOLTAGE_LEVEL_VALUE_PH%@2", + "param_value": "265.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 32328, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_LEVEL_VALUE_PH%@2", + "param_value": "47.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32329, + "param_name": "I18N_COMMON_OVER_FREQUENCY_LEVEL_VALUE_PH%@2", + "param_value": "52.00", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32370, + "param_name": "I18N_COMMON_UNDER_VOLTAGE_LEVEL_TIME_PH%@2", + "param_value": "1.50", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32372, + "param_name": "I18N_COMMON_OVER_VOLTAGE_LEVEL_TIME_PH%@2", + "param_value": "0.10", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32374, + "param_name": "I18N_COMMON_UNDER_FREQUENCY_LEVEL_TIME_PH%@2", + "param_value": "1.00", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32376, + "param_name": "I18N_COMMON_OVER_FREQUENCY_LEVEL_TIME_PH%@2", + "param_value": "0.10", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32318, + "param_name": "I18N_COMMON_OVERVOLTAGE_PROTECTION_RECOVERY_VALUE", + "param_value": "253.0", + "unit": "V", + "value_name": "" + }, { + "param_addr": 32319, + "param_name": "I18N_COMMON_UNDERVOLTAGE_PROTECTION_RECOVERY_VALUE", + "param_value": "204.9", + "unit": "V", + "value_name": "" + }, { + "param_addr": 32320, + "param_name": "I18N_COMMON_OVERFREQUENCY_PROTECTION_RECOVERY_VALUE", + "param_value": "50.15", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32321, + "param_name": "I18N_COMMON_UNDERFREQUENCY_PROTECTION_RECOVERY_VALUE", + "param_value": "47.50", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32535, + "param_name": "I18N_COMMON_PARALLEL_CONDITION", + "param_value": "170", + "unit": "", + "value_name": "I18N_COMMON_ENABLE" + }, { + "param_addr": 32536, + "param_name": "I18N_COMMON_PARALLEL_FREQUENCY_LOWER_LIMIT", + "param_value": "47.50", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32537, + "param_name": "I18N_COMMON_PARALLEL_FREQUENCY_HIGH_LIMIT", + "param_value": "50.15", + "unit": "Hz", + "value_name": "" + }, { + "param_addr": 32549, + "param_name": "I18N_COMMON_PARALLEL_VOLTAGE_LOWER_LIMIT", + "param_value": "89.1", + "unit": "%", + "value_name": "" + }, { + "param_addr": 32550, + "param_name": "I18N_COMMON_PARALLEL_VOLTAGE_HIGH_LIMIT", + "param_value": "110.0", + "unit": "%", + "value_name": "" + }, { + "param_addr": 32551, + "param_name": "I18N_COMMON_PARALLEL_DETECTION_TIME", + "param_value": "60", + "unit": "s", + "value_name": "" + }, { + "param_addr": 32552, + "param_name": "I18N_COMMON_PARALLEL_ACTIVE_UP_RATE", + "param_value": "16", + "unit": "%", + "value_name": "" + }] + }] + } +} +``` + +### Energy Management Parameters + +```sh-session +❯ curl 'http://10.10.10.219/device/getParam?token=4699b424-0093-45b8-b4e6-4f41a662a7e2&lang=en_us&time123456=1661832522753&dev_id=1&dev_type=35&dev_code=3343&type=9' +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "list": [{ + "param_id": 1, + "param_addr": 33146, + "param_pid": -1, + "param_type": 1, + "accuracy": 0, + "param_name": "I18N_COMMON_ENERGY_MANAGEMENT_MODE", + "param_value": "0", + "unit": "", + "relation": "", + "regulation": "", + "range": "", + "options": [{ + "name": "I18N_COMMON_SELF_CONSUMPTION_MODE", + "value": "0" + }, { + "name": "I18N_COMMON_FORCE_MODE_OPERATION", + "value": "2" + }, { + "name": "I18N_COMMON_EXTERNAL_ENERGY_SCH_MODE", + "value": "3" + }, { + "name": "I18N_COMMON_MEASURING_POIN_2", + "value": "4" + }, { + "name": "I18N_10RT_SEPT_1527758", + "value": "8" + }] + }, { + "param_id": 4, + "param_addr": 33151, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2620", + "param_value": "0", + "unit": "h", + "relation": "", + "regulation": "", + "range": "[0~23]", + "options": "" + }, { + "param_id": 5, + "param_addr": 33152, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2619", + "param_value": "0", + "unit": "min", + "relation": "", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 6, + "param_addr": 33153, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2616", + "param_value": "24", + "unit": "h", + "relation": "", + "regulation": "", + "range": "[0~24]", + "options": "" + }, { + "param_id": 7, + "param_addr": 33154, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2615", + "param_value": "0", + "unit": "min&I18N_COMMON_PARAMS_SETTING_TIP", + "relation": "", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 8, + "param_addr": 33155, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2622", + "param_value": "0", + "unit": "h", + "relation": "", + "regulation": "", + "range": "[0~23]", + "options": "" + }, { + "param_id": 9, + "param_addr": 33156, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2621", + "param_value": "0", + "unit": "min", + "relation": "", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 10, + "param_addr": 33157, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2618", + "param_value": "24", + "unit": "h", + "relation": "", + "regulation": "", + "range": "[0~24]", + "options": "" + }, { + "param_id": 11, + "param_addr": 33158, + "param_pid": -1, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_2617", + "param_value": "0", + "unit": "min&I18N_COMMON_PARAMS_SETTING_TIP", + "relation": "", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 12, + "param_addr": 33179, + "param_pid": -1, + "param_type": 1, + "accuracy": 0, + "param_name": "I18N_COMMON_WEEKEND_ENABLE", + "param_value": "170", + "unit": "", + "relation": "", + "regulation": "", + "range": "", + "options": [{ + "name": "I18N_COMMON_ENABLE", + "value": "170" + }, { + "name": "I18N_COMMON_PARA_OFF", + "value": "85" + }] + }, { + "param_id": 13, + "param_addr": 33180, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6186", + "param_value": "0", + "unit": "h", + "relation": "[170]", + "regulation": "", + "range": "[0~23]", + "options": "" + }, { + "param_id": 14, + "param_addr": 33181, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6185", + "param_value": "0", + "unit": "min", + "relation": "[170]", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 15, + "param_addr": 33182, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6182", + "param_value": "24", + "unit": "h", + "relation": "[170]", + "regulation": "", + "range": "[0~24]", + "options": "" + }, { + "param_id": 16, + "param_addr": 33183, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6181", + "param_value": "0", + "unit": "min&I18N_COMMON_PARAMS_SETTING_TIP", + "relation": "[170]", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 17, + "param_addr": 33184, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6188", + "param_value": "0", + "unit": "h", + "relation": "[170]", + "regulation": "", + "range": "[0~23]", + "options": "" + }, { + "param_id": 18, + "param_addr": 33185, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6187", + "param_value": "0", + "unit": "min", + "relation": "[170]", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 19, + "param_addr": 33186, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6184", + "param_value": "24", + "unit": "h", + "relation": "[170]", + "regulation": "", + "range": "[0~24]", + "options": "" + }, { + "param_id": 20, + "param_addr": 33187, + "param_pid": 12, + "param_type": 2, + "accuracy": 0, + "param_name": "I18N_CONFIG_KEY_6183", + "param_value": "0", + "unit": "min&I18N_COMMON_PARAMS_SETTING_TIP", + "relation": "[170]", + "regulation": "", + "range": "[0~59]", + "options": "" + }, { + "param_id": 21, + "param_addr": 33208, + "param_pid": -1, + "param_type": 1, + "accuracy": 0, + "param_name": "I18N_COMMON_FORCED_CHARGE_ENABLE", + "param_value": "85", + "unit": "", + "relation": "", + "regulation": "", + "range": "", + "options": [{ + "name": "I18N_COMMON_ENABLE", + "value": "170" + }, { + "name": "I18N_COMMON_PARA_OFF", + "value": "85" + }] + }, { + "param_id": 33, + "param_addr": 33275, + "param_pid": -1, + "param_type": 1, + "accuracy": 0, + "param_name": "I18N_COMMON_DO_FUNCTION_CONFIG", + "param_value": "0", + "unit": "", + "relation": "", + "regulation": "", + "range": "", + "options": [{ + "name": "I18N_COMMON_PARA_OFF", + "value": "0" + }, { + "name": "I18N_COMMON_LOAD1_REGULATION_MODE", + "value": "1" + }, { + "name": "I18N_COMMON_GROUND_DETECTION_ALARM", + "value": "2" + }, { + "name": "I18N_10RT_SEPT_1527758", + "value": "3" + }] + }] + } +} +``` + +## WebSocket + +### Connect + +```jsonc +// Request +{"lang":"en_us","token":"","service":"connect"} + +// Response, includes token to use +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "connect", + "token": "12345678-9012-4000-0000-abcdef123456", + "uid": 1, + "tips_disable": 1 + } +} +``` + +### Ping + +This is not a WebSocket ping, it's still a WebSocket text message, which the WiNet-S treats as a kind of keep-alive? + +```jsonc +// Request +{"lang":"zh_cn","service":"ping","token":"","id":"cf1530ff-71e5-456a-8450-767793ba5781"} + +// Response +{ + "result_code": 1, + "result_msg": "success" +} +``` + +Of note: + +* the UUID in the `id` field is always random +* `lang` must be present, but doesn't have to be zh_cn, even though Web UI uses that +* `token` is always empty, and the field doesn't have to be included + +### Login + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service":"login","passwd":"pw8888","username":"admin"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "login", + "token": "c3173fe1-380d-4406-ad54-84d77125b93a", + "passwd": "pw8888", + "uid": 3, + "role": 0, + "tips_disable": 1 + } +} +``` + +### Logout + +```jsonc +// Request +{"lang":"en_us","token":"c3173fe1-380d-4406-ad54-84d77125b93a","service":"logout"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "logout" + } +} +``` + +### State + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service":"state"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "state", + "total_fault": "0", + "total_alarm": "0", + "wireless_conn_sts": "0", + "wifi_conn_sts": "0", + "eth_conn_sts": "1", + "eth2_conn_sts": "0", + "wireless_cmd": "170", + "wifi_cmd": "170", + "cloud_conn_sts": "1", + "server_net_type": "0" + } +} +``` + +### Statistics + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service":"statistics"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "statistics", + "list": [{ + "today_energy": "--", + "today_energy_unit": "kWh", + "total_energy": "--", + "total_energy_unit": "kWh", + "curr_power": "0.67", + "curr_power_unit": "kW", + "curr_reactive": "0.00", + "curr_reactive_unit": "kvar", + "rated_power": "5.00", + "rated_power_unit": "kW", + "rated_reactive": "3.00", + "rated_reactive_unit": "kvar", + "adjust_power_uplimit": "5.00", + "adjust_power_uplimit_unit": "kW", + "adjust_reactive_uplimit": "3.00", + "adjust_reactive_uplimit_unit": "kvar", + "adjust_reactive_lowlimit": "-3.00", + "adjust_reactive_lowlimit_unit": "kvar" + }, { + "online_num": "2", + "online_num_unit": "", + "offline_num": "0", + "offline_num_unit": "" + }], + "count": 2 + } +} +``` + +### Runtime + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service":"runtime"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "runtime", + "count": 1, + "list": [{ + "dev_name": "SH5.0RS(COM1-001)", + "dev_model": "SH5.0RS", + "dev_type": 35, + "dev_procotol": 2, + "today_energy": "--", + "today_energy_unit": "kWh", + "total_energy": "--", + "total_energy_unit": "kWh", + "dev_state": "33280", + "dev_state_unit": "", + "curr_power": "0.67", + "curr_power_unit": "kW", + "reactive_power": "0.00", + "reactive_power_unit": "kvar" + }], + "connect_count": 1, + "off_count": 0 + } +} +``` + +### Device List + +Unclear what `type` and `is_check_token` are for. + +Response body looks pretty similar to the HTTP request. The actual devices have the same keys and values, except for the +empty `list` array when requesting over the WS. + + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service":"devicelist","type":"0","is_check_token":"0"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "devicelist", + "list": [{ + "id": 1, + "dev_id": 1, + "dev_code": 3343, + "dev_type": 35, // This appears to correspond to the `getType` HTTP request + "dev_procotol": 2, + "inv_type": 0, + "dev_sn": "REDACTED", + "dev_name": "SH5.0RS(COM1-001)", + "dev_model": "SH5.0RS", + "port_name": "COM1", + "phys_addr": "1", // This corresponds to the Modbus slave/unit ID + "logc_addr": "1", + "link_status": 1, + "init_status": 1, + "dev_special": "0", + "list": [] + }, { + "id": 2, + "dev_id": 2, + "dev_code": 8424, + "dev_type": 44, + "dev_procotol": 0, + "inv_type": 0, + "dev_sn": "REDACTED", + "dev_name": "SBR128(COM1-200)", + "dev_model": "SBR128", + "port_name": "COM1", + "phys_addr": "200", + "logc_addr": "2", + "link_status": 1, + "init_status": 255, + "dev_special": "0", + "list": [] + }], + "count": 2 + } +} +``` + +### Realtime Values (Inverter) + +Of note: + +* `time123456` is not static; likely just unix timestamp, but unclear if necessary + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","dev_id":"1","service":"real","time123456":1661762597181} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "real", + "list": [{ + "data_name": "I18N_COMMON_TOTAL_GRID_RUNNING_TIME", + "data_value": "--", + "data_unit": "h" + }, { + "data_name": "I18N_COMMON_PV_DAYILY_ENERGY_GENERATION", + "data_value": "7.5", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_PV_TOTAL_ENERGY_GENERATION", + "data_value": "1473.5", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_DAILY_POWER_YIELD", + "data_value": "--", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_YIELD", + "data_value": "--", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_RUNNING_STATE", + "data_value": "I18N_COMMON_DISPATCH_RUN", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_BUS_VOLTAGE", + "data_value": "379.6", + "data_unit": "V" + }, { + "data_name": "I18N_COMMON_AIR_TEM_INSIDE_MACHINE", + "data_value": "25.8", + "data_unit": "℃" + }, { + "data_name": "I18N_COMMON_SQUARE_ARRAY_INSULATION_IMPEDANCE", + "data_value": "1107", + "data_unit": "kΩ" + }, { + "data_name": "I18N_CONFIG_KEY_1001188", + "data_value": "100.0", + "data_unit": "%" + }, { + "data_name": "I18N_COMMON_FEED_NETWORK_TOTAL_ACTIVE_POWER", + "data_value": "0.00", + "data_unit": "kW" + }, { + "data_name": "I18N_CONFIG_KEY_4060", + "data_value": "0.00", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_DAILY_FEED_NETWORK_VOLUME", + "data_value": "--", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_FEED_NETWORK_VOLUME", + "data_value": "141.1", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_ENERGY_GET_FROM_GRID_DAILY", + "data_value": "--", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_ELECTRIC_GRID_GET_POWER", + "data_value": "283.2", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_DAILY_FEED_NETWORK_PV", + "data_value": "0.0", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_FEED_NETWORK_PV", + "data_value": "129.3", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_LOAD_TOTAL_ACTIVE_POWER", + "data_value": "0.682", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_DAILY_DIRECT_CONSUMPTION_ELECTRICITY_PV", + "data_value": "3.8", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_DIRECT_POWER_CONSUMPTION_PV", + "data_value": "523.8", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_DCPOWER", + "data_value": "0.00", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_TOTAL_ACTIVE_POWER", + "data_value": "0.68", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_TOTAL_REACTIVE_POWER", + "data_value": "0.00", + "data_unit": "kvar" + }, { + "data_name": "I18N_COMMON_TOTAL_APPARENT_POWER", + "data_value": "0.68", + "data_unit": "kVA" + }, { + "data_name": "I18N_COMMON_TOTAL_POWER_FACTOR", + "data_value": "1.000", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_GRID_FREQUENCY", + "data_value": "49.99", + "data_unit": "Hz" + }, { + "data_name": "I18N_COMMONUA", + "data_value": "239.1", + "data_unit": "V" + }, { + "data_name": "I18N_COMMON_FRAGMENT_RUN_TYPE1", + "data_value": "3.2", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_PHASE_A_BACKUP_CURRENT_QFKYGING", + "data_value": "3.5", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_PHASE_B_BACKUP_CURRENT_ODXCTVMS", + "data_value": "0.0", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_PHASE_C_BACKUP_CURRENT_PBSQLZIX", + "data_value": "0.0", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_PHASE_A_BACKUP_POWER_BRBJDGVB", + "data_value": "0.666", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_PHASE_B_BACKUP_POWER_OCDHLMZB", + "data_value": "0.000", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_PHASE_C_BACKUP_POWER_HAMBBGNL", + "data_value": "0.000", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_TOTAL_BACKUP_POWER_WLECIVPM", + "data_value": "0.666", + "data_unit": "kW" + }], + "count": 36 + } +} +``` + +### Realtime Values (battery) + +* I did not always have a separate battery device listed, until Sungrow upgraded the battery firmware remotely. There is a `real_battery` service below which uses the _inverter_ device ID. + +```jsonc +// Request +// Same as inverter realtime values, but with `dev_id` of battery +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","dev_id":"2","service":"real","time123456":1661762897571} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "real", + "list": [{ + "data_name": "I18N_COMMON_BATTERY_VOLTAGE", + "data_value": "264.3", + "data_unit": "V" + }, { + "data_name": "I18N_COMMON_BATTERY_CURRENT", + "data_value": "2.6", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_BATTERY_TEMPERATURE", + "data_value": "16.5", + "data_unit": "℃" + }, { + "data_name": "I18N_COMMON_REMAIN_BATTERY_POWER", + "data_value": "90.1", + "data_unit": "%" + }, { + "data_name": "I18N_COMMON_BATTARY_HEALTH", + "data_value": "100", + "data_unit": "%" + }, { + "data_name": "I18N_COMMON_TOTAL_BATTERY_CHARGE", + "data_value": "575.9", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_BATTERY_DISCHARGE_BMS", + "data_value": "534.9", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_BATTERY_OPERATION_STATUS", + "data_value": "I18N_COMMON_STATUS_RUN", + "data_unit": "" + }], + "count": 8 + } +} +``` + +### Battery information + +* `time123456` is not static; likely just unix timestamp, but unclear if necessary + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","dev_id":"1","service": "real_battery","time123456":1661762736979} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "real_battery", + "list": [{ + "data_name": "I18N_CONFIG_KEY_3907", + "data_value": "0.000", + "data_unit": "kW" + }, { + "data_name": "I18N_CONFIG_KEY_3921", + "data_value": "1.068", + "data_unit": "kW" + }, { + "data_name": "I18N_COMMON_BATTERY_VOLTAGE", + "data_value": "261.3", + "data_unit": "V" + }, { + "data_name": "I18N_COMMON_BATTERY_CURRENT", + "data_value": "4.0", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_BATTERY_TEMPERATURE", + "data_value": "16.4", + "data_unit": "℃" + }, { + "data_name": "I18N_COMMON_BATTERY_SOC", + "data_value": "79.5", + "data_unit": "%" + }, { + "data_name": "I18N_COMMON_BATTARY_HEALTH", + "data_value": "100.0", + "data_unit": "%" + }, { + "data_name": "I18N_COMMON_MAX_CHARGE_CURRENT_BMS", + "data_value": "30", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_MAX_DISCHARGE_CURRENT_BMS", + "data_value": "30", + "data_unit": "A" + }, { + "data_name": "I18N_COMMON_DAILY_BATTERY_CHARGE_PV", + "data_value": "3.7", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_BATTERY_CHARGE_PV", + "data_value": "820.4", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_DAILY_BATTERY_DISCHARGE", + "data_value": "7.9", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_BATTRY_DISCHARGE", + "data_value": "511.5", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_DAILY_BATTERY_CHARGE", + "data_value": "6.9", + "data_unit": "kWh" + }, { + "data_name": "I18N_COMMON_TOTAL_BATTERY_CHARGE", + "data_value": "574.5", + "data_unit": "kWh" + }], + "count": 15 + } +} +``` + +### WiNet info + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service": "local"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "local", + "list": [{ + "data_name": "I18N_COMMON_SYSTEM_TIME", + "data_value": "2022-08-29 18:12", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_ETH_IP_ADDRESS", + "data_value": "10.10.10.219", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_ETH_MAC_ADDRESS", + "data_value": "e8:68:e7:33:b6:6b", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_WIFI_AP_IP_ADDRESS", + "data_value": "--", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_WIFI_STA_IP_ADDRESS", + "data_value": "--", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_WLAN_MAC_ADDRESS", + "data_value": "e8:68:e7:33:b6:68", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_WIFI_SIGNAL_STRN", + "data_value": "--", + "data_unit": "dBm" + }, { + "data_name": "I18N_COMMON_FTP_UPLOAD_TIME", + "data_value": "--", + "data_unit": "" + }, { + "data_name": "I18N_COMMON_FTP_UPLOAD_RESULT", + "data_value": "--", + "data_unit": "" + }, { + "data_name": "ETH1 IPV6", + "data_value": "--", + "data_unit": "" + }, { + "data_name": "WIFI IPV6", + "data_value": "--", + "data_unit": "" + }], + "count": 8 + } +} +``` + +### Notice + +This isn't requested but seems to be pushed to notify client. So far, I've only seen it to push an error that the user has been logged out due to user limit, but it may be used in other ways. + +```jsonc +{ + "result_code": 100, + "result_msg": "normal user limit", + "result_data": { + "service": "notice" + } +} +``` + +### Modbus forwarders + +```jsonc +// Request +{"lang":"en_us","token":"12345678-9012-4000-0000-abcdef123456","service": "proto_modbus104"} + +// Response +{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "service": "proto_modbus104", + "list": [{ + "data_name": "MODBUS-TCP IP1", + "data_value": "10.10.10.73", + "data_unit": "" + }, { + "data_name": "MODBUS-TCP IP2", + "data_value": "--", + "data_unit": "" + }, { + "data_name": "MODBUS-TCP IP3", + "data_value": "--", + "data_unit": "" + }], + "count": 3 + } +} +``` diff --git a/sungrow-winets/README.md b/sungrow-winets/README.md new file mode 100644 index 0000000..0dfd8eb --- /dev/null +++ b/sungrow-winets/README.md @@ -0,0 +1,9 @@ +# Sungrow WiNet-S Client + +This allows connecting to Sungrow inverters which use a WiNet-S networking dongle. + +No attempt has been made to support other dongles, inverters, etc. + +## Acknowledgements + +* https://github.com/bohdan-s/SungrowModbusWebClient \ No newline at end of file diff --git a/sungrow-winets/examples/poll.rs b/sungrow-winets/examples/poll.rs new file mode 100644 index 0000000..4921e9c --- /dev/null +++ b/sungrow-winets/examples/poll.rs @@ -0,0 +1,21 @@ +use std::time::Duration; + +use sungrow_winets::*; + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt::init(); + + let host = std::env::args() + .nth(1) + .expect("must pass host/IP of WiNet-S as first argument"); + + let client = Client::new(host).await?; + + let mut tick = tokio::time::interval(Duration::from_millis(200)); + loop { + tick.tick().await; + let data = client.running_state().await; + println!("{:?}", &data); + } +} diff --git a/sungrow-winets/examples/set_forced_power.rs b/sungrow-winets/examples/set_forced_power.rs new file mode 100644 index 0000000..1b9ee78 --- /dev/null +++ b/sungrow-winets/examples/set_forced_power.rs @@ -0,0 +1,43 @@ +use sungrow_winets::*; + +// The documented register for setting the charge/discharge power for forced mode is 13052. +// +// HOWEVER, this register can't be set (neither via Modbus nor via WiNet-S register setting). On the other hand, the +// Energy Management Parameters tab lets you set this value, but inspecting the web requests reveals it uses register +// 33148! +// +// This example, therefore, uses register 33148. However, unlike the documented 13052, the value here is set in +// multiples of 10W (e.g. `200` is 2000 Watts). +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt::init(); + + let host = std::env::args() + .nth(1) + .expect("must pass host/IP of WiNet-S as first argument"); + + let power: u16 = str::parse( + &std::env::args() + .nth(2) + .expect("pass power in watts as second argument"), + ) + .expect("invalid uint"); + + let client = Client::new(host).await?; + + let was = client + .read_register(RegisterType::Holding, 33148, 1) + .await?; + + println!("power was {} W", 10 * &was[0]); + + client.write_register(33148, &[power / 10]).await?; + + let is = client + .read_register(RegisterType::Holding, 33148, 1) + .await?; + + println!("power is now {} W", 10 * &is[0]); + + Ok(()) +} diff --git a/sungrow-winets/src/lib.rs b/sungrow-winets/src/lib.rs new file mode 100644 index 0000000..e1b153a --- /dev/null +++ b/sungrow-winets/src/lib.rs @@ -0,0 +1,590 @@ +use serde::Deserialize; +use serde_aux::prelude::*; +use thiserror::Error; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; +use tracing::{debug, error, info, instrument}; + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum Error { + #[error(transparent)] + WebsocketErr(#[from] tungstenite::error::Error), + + #[error(transparent)] + HttpErr(#[from] reqwest::Error), + + // Thank you stranger https://github.com/dtolnay/thiserror/pull/175 + #[error("{code}{}", match .message { + Some(msg) => format!(" - {}", &msg), + None => "".to_owned(), + })] + SungrowError { code: u16, message: Option }, + + #[error(transparent)] + JSONError(#[from] serde_json::Error), + + #[error("Expected attached data")] + ExpectedData, + + #[error("No token")] + NoToken, +} + +impl From for std::io::Error { + fn from(e: Error) -> Self { + use std::io::ErrorKind; + // TODO: Likely there are reasonable mappings from some of our errors to specific io Errors but, for now, this + // is just so tokio_modbus-winets can fail conveniently. + std::io::Error::new(ErrorKind::Other, e) + } +} + +#[derive(Debug)] +pub struct Client { + http: reqwest::Client, + host: String, + token: String, + devices: Vec, +} + +const WS_PORT: u16 = 8082; + +type Result = std::result::Result; + +impl Client { + pub async fn new(host: H) -> Result + where + H: Into, + { + let host = host.into(); + let ws_url = format!("ws://{}:{}/ws/home/overview", &host, WS_PORT); + + use futures_util::SinkExt; + use futures_util::StreamExt; + let (mut ws, _) = connect_async(ws_url).await?; + + ws.send(Message::Text( + serde_json::json!({"lang":"en_us","token":"","service":"connect"}).to_string(), + )) + .await?; + + // TODO: maintan WS connection, pinging and watching for updated tokens + let token = if_chain::if_chain! { + if let Some(Ok(Message::Text(msg))) = ws.next().await ; + if let Ok(value) = serde_json::from_str::(&msg); + if let Some(ResultData::WebSocketMessage(WebSocketMessage::Connect { token })) = value.data; + then { + debug!(token, "Got WiNet-S token"); + token + } else { + // TODO: it might be that we get some other WS messages here that are fine so we might need to take a + // few WS messages to find the token. + return Err(Error::NoToken); + } + }; + Self::new_with_token(host, token).await + } + + pub async fn new_with_token(host: H, token: String) -> Result + where + H: Into, + { + let host = host.into(); + let http = reqwest::Client::new(); + + let data: ResultData = parse_response( + http.post(format!("http://{}/inverter/list", &host)) + .send() + .await?, + ) + .await?; + + if let ResultData::DeviceList(ResultList { items, .. }) = data { + Ok(Client { + token, + devices: items, + host, + http, + }) + } else { + Err(Error::ExpectedData) + } + } + + #[tracing::instrument(level = "debug")] + pub async fn read_register( + &self, + register_type: RegisterType, + address: u16, + count: u16, + ) -> Result> { + // FIXME: find device by phys_addr + let device = &self.devices[0]; + + #[derive(serde::Serialize)] + struct Params { + #[serde(rename = "type")] + type_: u8, + dev_id: u8, + dev_type: u8, + dev_code: u16, + param_type: u8, + param_addr: u16, + param_num: u16, + } + let request = self.get("/device/getParam").query(&Params { + type_: 3, + dev_id: device.dev_id, + dev_type: device.dev_type, + dev_code: device.dev_code, + param_type: register_type.param(), + param_addr: address, + param_num: count, + }); + let response = request.send().await?; + + let result = parse_response(response).await?; + + if let ResultData::GetParam { param_value } = result { + Ok(param_value) + } else { + Err(Error::ExpectedData) + } + } + + #[tracing::instrument(level = "debug")] + pub async fn write_register(&self, address: u16, data: &[u16]) -> Result<()> { + if data.is_empty() { + return Err(Error::ExpectedData); + } + // FIXME: find device by phys_addr + let device = &self.devices[0]; + + use serde_json::json; + let body = json!({ + "lang": "en_us", + "token": &self.token, + "dev_id": device.dev_id, + "dev_type": device.dev_type, + "dev_code": device.dev_code, + "param_addr": address.to_string(), + "param_size": data.len().to_string(), + "param_value": data[0].to_string(), + }); + let request = self + .http + .post(format!("http://{}{}", &self.host, "/device/setParam")) + .json(&body); + let response = request.send().await?; + parse_response(response).await?; + Ok(()) + } + + pub async fn running_state(&self) -> Result { + let raw = *self + .read_register(RegisterType::Input, 13001, 1) + .await? + .first() + .ok_or(Error::ExpectedData)?; + let bits: RunningStateBits = raw.into(); + + let battery_state = if bits.intersects(RunningStateBits::BatteryCharging) { + BatteryState::Charging + } else if bits.intersects(RunningStateBits::BatteryDischarging) { + BatteryState::Discharging + } else { + BatteryState::Inactive + }; + + let trading_state = if bits.intersects(RunningStateBits::ImportingPower) { + TradingState::Importing + } else if bits.intersects(RunningStateBits::ExportingPower) { + TradingState::Exporting + } else { + TradingState::Inactive + }; + + Ok(RunningState { + battery_state, + trading_state, + generating_pv_power: bits.intersects(RunningStateBits::GeneratingPVPower), + positive_load_power: bits.intersects(RunningStateBits::LoadActive), + power_generated_from_load: bits.intersects(RunningStateBits::GeneratingPVPower), + state: bits, + }) + } + + fn get(&self, path: &str) -> reqwest::RequestBuilder { + self.http + .get(format!("http://{}{}", &self.host, path)) + .query(&[("lang", "en_us"), ("token", self.token.as_str())]) + } +} + +#[derive(Debug)] +pub enum BatteryState { + Charging, + Discharging, + Inactive, +} + +#[derive(Debug)] +pub enum TradingState { + Importing, + Exporting, + Inactive, +} + +#[derive(Debug)] +pub struct RunningState { + state: RunningStateBits, + pub battery_state: BatteryState, + pub trading_state: TradingState, + pub generating_pv_power: bool, + pub positive_load_power: bool, + pub power_generated_from_load: bool, +} + +impl RunningState { + pub fn raw(&self) -> RunningStateBits { + self.state + } +} + +// See Appendix 1.2 of Sungrow modbus documentation for hybrid inverters +#[bitmask_enum::bitmask(u16)] +#[derive(Debug)] +pub enum RunningStateBits { + GeneratingPVPower = 0b00000001, + BatteryCharging = 0b00000010, + BatteryDischarging = 0b00000100, + LoadActive = 0b00001000, + LoadReactive = 0b00000000, + ExportingPower = 0b00010000, + ImportingPower = 0b00100000, + PowerGeneratedFromLoad = 0b0100000, +} + +#[tracing::instrument(level = "debug")] +async fn parse_response(response: reqwest::Response) -> Result +where + Result: From, +{ + let body = response.text().await?; + debug!(%body, "parsing"); + let sg_result = serde_json::from_slice::(body.as_bytes()); + sg_result?.into() +} + +#[derive(Debug)] +pub enum RegisterType { + Input, + Holding, +} + +impl RegisterType { + fn param(&self) -> u8 { + match self { + Self::Input => 0, + Self::Holding => 1, + } + } +} + +// { +// "id": 1, +// "dev_id": 1, +// "dev_code": 3343, +// "dev_type": 35, +// "dev_procotol": 2, +// "inv_type": 0, +// "dev_sn": "REDACTED", +// "dev_name": "SH5.0RS(COM1-001)", +// "dev_model": "SH5.0RS", +// "port_name": "COM1", +// "phys_addr": "1", +// "logc_addr": "1", +// "link_status": 1, +// "init_status": 1, +// "dev_special": "0", +// "list": [] +// } +#[derive(Debug, Deserialize)] +struct Device { + dev_id: u8, + dev_code: u16, + + // Available from `GET /device/getType`: + // + // { + // "result_code": 1, + // "result_msg": "success", + // "result_data": { + // "count": 5, + // "list": [{ + // "name": "I18N_COMMON_STRING_INVERTER", + // "value": 1 + // }, { + // "name": "I18N_COMMON_SOLAR_INVERTER", + // "value": 21 + // }, { + // "name": "I18N_COMMON_STORE_INVERTER", + // "value": 35 + // }, { + // "name": "I18N_COMMON_AMMETER", + // "value": 18 + // }, { + // "name": "I18N_COMMON_CHARGING_PILE", + // "value": 46 + // }] + // } + // } + // + // TODO: Extract into enum represented by underlying number? + dev_type: u8, + + // unit/slave ID + #[serde(deserialize_with = "serde_aux::prelude::deserialize_number_from_string")] + phys_addr: u8, + // UNUSED: + // + // id: u8, + // dev_protocol: u8, + // dev_sn: String, + // dev_model: String, + // port_name: String, + // logc_address: String, + // link_status: u8, + // init_status: u8, + // dev_special: String, + // list: Option> // unknown +} + +#[test] +fn test_deserialize_device() { + let json = r#"{ + "id": 1, + "dev_id": 1, + "dev_code": 3343, + "dev_type": 35, + "dev_procotol": 2, + "inv_type": 0, + "dev_sn": "REDACTED", + "dev_name": "SH5.0RS(COM1-001)", + "dev_model": "SH5.0RS", + "port_name": "COM1", + "phys_addr": "1", + "logc_addr": "1", + "link_status": 1, + "init_status": 1, + "dev_special": "0" + }"#; + + let dev: Device = serde_json::from_str(json).unwrap(); + + assert!(matches!( + dev, + Device { + dev_id: 1, + dev_code: 3343, + dev_type: 35, + phys_addr: 1 + } + )); +} +#[derive(Debug, Deserialize)] +#[serde(tag = "service", rename_all = "lowercase")] +enum WebSocketMessage { + Connect { token: String }, + + DeviceList { list: Vec }, + + // Not yet used: + // State, // system state + // Real, // real time info + // Notice, // on some error messages? + // Statistics, + // Runtime, + // Local, + // Fault, + // #[serde(rename = "proto_modbus104")] + // Modbus, + Other, +} + +#[derive(Debug, Deserialize)] +struct ResultList { + count: u16, + #[serde(rename = "list")] + items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ResultData { + // TODO: custom deserializer into words + GetParam { + #[serde(deserialize_with = "words_from_string")] + param_value: Vec, + }, + DeviceList(ResultList), + WebSocketMessage(WebSocketMessage), + + // // String = name - http:///i18n/en_US.properties has the translations for these item names + // // i32 = value - unclear if this is always an int, so making this a JSON::Value for now + // GetType(ResultList<(String, serde_json::Value)>), + // Product { + // #[serde(rename = "product_name")] + // name: String, + // #[serde(rename = "product_code")] + // code: u8, + // }, + Other, +} + +#[test] +fn test_deserialize_get_param() { + let json = r#"{"param_value": "82 00 "}"#; + let data: ResultData = serde_json::from_str(json).unwrap(); + assert!(matches!(data, ResultData::GetParam { .. })); + + let json = r#"{ + "result_code": 1, + "result_msg": "success", + "result_data": { + "param_value": "82 00 " + } + }"#; + + let data: SungrowResult = serde_json::from_str(json).unwrap(); + assert!(matches!( + data, + SungrowResult { + code: 1, + message: Some(m), + data: Some(ResultData::GetParam { .. }) + } if m == "success" + )); +} + +// TODO: can I make this an _actual_ `Result`? +// - if code == 1, it is Ok(SungrowData), otherwise create error from code and message? +#[derive(Deserialize)] +struct SungrowResult { + // 1 = success + // 100 = hit user limit? + // { + // "result_code": 100, + // "result_msg": "normal user limit", + // "result_data": { + // "service": "notice" + // } + // } + #[serde(rename = "result_code")] + code: u16, + + #[serde(rename = "result_msg")] + // http:///i18n/en_US.properties has the translations for messages (only ones which start with I18N_*) + message: Option, // at least one result I saw (code = 200 at the time) had no message :\ + + #[serde(rename = "result_data")] + data: Option, +} + +impl From for Result> { + fn from(sg_result: SungrowResult) -> Self { + match sg_result { + SungrowResult { code: 1, data, .. } => Ok(data), + SungrowResult { code, message, .. } => Err(Error::SungrowError { code, message }), + } + } +} +impl From for Result { + fn from(sg_result: SungrowResult) -> Self { + let data: Result> = sg_result.into(); + + if let Some(data) = data? { + Ok(data) + } else { + Err(Error::ExpectedData) + } + } +} +impl From for Result<()> { + fn from(sg_result: SungrowResult) -> Self { + let data: Result> = sg_result.into(); + data.map(|_| ()) + } +} + +// WiNet-S returns data encoded as space-separated hex byte string. E.g.: +// +// "aa bb cc dd " (yes, including trailing whitespace) +// +// Modbus uses u16 "words" instead of bytes, and the data above should always represent this, so we can take groups +// of 2 and consume them as a hex-represented u16. +// +// TODO: can be simpler once https://github.com/vityafx/serde-aux/issues/26 is resolved +fn words_from_string<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + StringOrVecToVec::with_separator(' ').into_deserializer()(deserializer).map( + |vec: Vec| { + vec.chunks_exact(2) + .map(|chunk| { + let bytes: [u8; 2] = chunk + .iter() + .map(|byte_str| { + u8::from_str_radix(byte_str, 16).expect("API shouldn't return bad hex") + }) + .collect::>() + .try_into() + .expect("we always have two elements, because of `chunks_exact`"); + u16::from_be_bytes(bytes) + }) + .collect::>() + }, + ) +} + +#[test] +fn test_words_from_string() { + #[derive(serde::Deserialize, Debug)] + struct MyStruct { + #[serde(deserialize_with = "words_from_string")] + list: Vec, + } + + let s = r#" { "list": "00 AA 00 01 00 0D 00 1E 00 0F 00 00 00 55 " } "#; + let a: MyStruct = serde_json::from_str(s).unwrap(); + assert_eq!( + &a.list, + &[0x00AA, 0x0001, 0x000D, 0x001E, 0x000F, 0x0000, 0x0055] + ); +} + +#[test] +#[ignore] // For a bug report in serde_aux: https://github.com/vityafx/serde-aux/issues/26 +fn test_bytes_from_string() { + fn bytes_from_string<'de, D>(deserializer: D) -> std::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + StringOrVecToVec::new(' ', |s| { + println!("{:?}", &s); + u8::from_str_radix(s, 16) + }) + .into_deserializer()(deserializer) + } + + #[derive(serde::Deserialize, Debug)] + struct MyStruct { + #[serde(deserialize_with = "bytes_from_string")] + list: Vec, + } + + let s = r#" { "list": "a1 b2 c3 d4 " } "#; + let a: MyStruct = serde_json::from_str(s).unwrap(); + assert_eq!(&a.list, &[0xa1, 0xb2, 0xc3, 0xd4]); +} diff --git a/tokio_modbus-winets/Cargo.toml b/tokio_modbus-winets/Cargo.toml new file mode 100644 index 0000000..df23622 --- /dev/null +++ b/tokio_modbus-winets/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tokio_modbus-winets" +version = "0.1.0" +edition = "2021" +authors = ["Bo Jeanes "] + +[dependencies] +async-trait = "0.1.57" +sungrow-winets = { path = "../sungrow-winets" } +tokio-modbus = { version = "0.5.3", features = [] } +tracing = "0.1.36" \ No newline at end of file diff --git a/tokio_modbus-winets/src/client.rs b/tokio_modbus-winets/src/client.rs new file mode 100644 index 0000000..a312174 --- /dev/null +++ b/tokio_modbus-winets/src/client.rs @@ -0,0 +1,20 @@ +use std::io::Error; +use tokio_modbus::client::Context; +use tokio_modbus::prelude::Client; +use tokio_modbus::slave::Slave; + +pub async fn connect(host: H) -> Result +where + H: Into, +{ + connect_slave(host, Slave(1)).await +} + +pub async fn connect_slave(host: H, slave: Slave) -> Result +where + H: Into, +{ + let context = crate::service::connect_slave(host, slave).await?; + let client: Box = Box::new(context); + Ok(Context::from(client)) +} diff --git a/tokio_modbus-winets/src/lib.rs b/tokio_modbus-winets/src/lib.rs new file mode 100644 index 0000000..a1a920e --- /dev/null +++ b/tokio_modbus-winets/src/lib.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod service; + +pub use client::{connect, connect_slave}; diff --git a/tokio_modbus-winets/src/service.rs b/tokio_modbus-winets/src/service.rs new file mode 100644 index 0000000..c6dd333 --- /dev/null +++ b/tokio_modbus-winets/src/service.rs @@ -0,0 +1,99 @@ +use std::io::Error; + +use tokio_modbus::{ + prelude::{Client, Request, Response}, + slave::{Slave, SlaveContext}, +}; +use tracing::{debug, error, info}; + +pub(crate) async fn connect_slave(host: H, _slave: Slave) -> Result +where + H: Into, +{ + let host: String = host.into(); + Ok(Context::new(host).await?) +} + +#[derive(Debug)] +pub struct Context { + // unit: Slave, + host: String, + service: sungrow_winets::Client, +} + +impl Context { + async fn new(host: String) -> Result { + let service = sungrow_winets::Client::new(&host).await?; + Ok(Self { host, service }) + } +} + +#[async_trait::async_trait] +impl Client for Context { + #[tracing::instrument(level = "debug")] + async fn call(&mut self, request: Request) -> Result { + use sungrow_winets::RegisterType; + use Request::*; + match request { + ReadInputRegisters(address, qty) => { + let words = self + .service + .read_register(RegisterType::Input, address, qty) + .await?; + Ok(Response::ReadInputRegisters(words)) + } + ReadHoldingRegisters(address, qty) => { + let words = self + .service + .read_register(RegisterType::Holding, address, qty) + .await?; + Ok(Response::ReadHoldingRegisters(words)) + } + WriteSingleRegister(address, word) => self + .call(Request::WriteMultipleRegisters(address, vec![word])) + .await + .map(|res| match res { + Response::WriteMultipleRegisters(address, _) => { + Response::WriteSingleRegister(address, word) + } + _ => panic!("this should not happen"), + }), + + WriteMultipleRegisters(address, words) => { + self.service.write_register(address, &words).await?; + Ok(Response::WriteMultipleRegisters( + address, + words.len().try_into().unwrap(), + )) + } + // NOTE: does this notionally read _then_ write or vice versa? If you read the address you are writing, are + // you supposed to get the old value or the new value? + ReadWriteMultipleRegisters(read_address, qty, write_address, words) => { + self.call(Request::WriteMultipleRegisters(write_address, words)) + .await?; + self.call(Request::ReadHoldingRegisters(read_address, qty)) + .await + .map(|res| match res { + Response::ReadHoldingRegisters(words) => { + Response::ReadWriteMultipleRegisters(words) + } + _ => panic!("this should not happen"), + }) + } + Disconnect => todo!(), + _ => unimplemented!("Sungrow doesn't use or expose this"), + } + } +} + +impl SlaveContext for Context { + // TODO: Technically, the battery is exposed (albeit only in some firmware versions of battery) as another slave on + // the WiNet-S. However, implementing accessing both will need to be thought about carefully such that the websocket + // is shared, due to the way the WiNet-S boots off sessions when there are too many accessers. + // Because the usecase is primarily to access the inverter and most, if not all, battery info is available via the + // inverter, this is not a priority to implement. + fn set_slave(&mut self, _slave: tokio_modbus::slave::Slave) { + unimplemented!() + // self.unit = slave; + } +}