1
0
Fork 0

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...
gh-action
Bo Jeanes 2022-08-30 16:28:33 +10:00
parent 2557d99d9c
commit e3d4d024ce
20 changed files with 2888 additions and 195 deletions

119
Cargo.lock generated
View File

@ -32,6 +32,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "ansi_term" name = "ansi_term"
version = "0.12.1" version = "0.12.1"
@ -87,6 +96,16 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.2" version = "0.10.2"
@ -126,6 +145,18 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 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]] [[package]]
name = "clap" name = "clap"
version = "3.2.17" version = "3.2.17"
@ -497,16 +528,16 @@ dependencies = [
] ]
[[package]] [[package]]
name = "hyper-rustls" name = "iana-time-zone"
version = "0.23.0" version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" checksum = "ad2bfd338099682614d3ee3fe0cd72e0b6a41ca6a87f6a74a3bd593c91650501"
dependencies = [ dependencies = [
"http", "android_system_properties",
"hyper", "core-foundation-sys",
"rustls", "js-sys",
"tokio", "wasm-bindgen",
"tokio-rustls", "winapi",
] ]
[[package]] [[package]]
@ -520,6 +551,12 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "if_chain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.1" version = "1.9.1"
@ -685,14 +722,11 @@ dependencies = [
name = "modbus-mqtt" name = "modbus-mqtt"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"bytes", "bytes",
"clap", "clap",
"futures-util",
"humantime-serde", "humantime-serde",
"itertools", "itertools",
"pretty_assertions", "pretty_assertions",
"reqwest",
"rumqttc", "rumqttc",
"rust_decimal", "rust_decimal",
"serde", "serde",
@ -701,7 +735,7 @@ dependencies = [
"tokio", "tokio",
"tokio-modbus", "tokio-modbus",
"tokio-serial", "tokio-serial",
"tokio-tungstenite", "tokio_modbus-winets",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
@ -740,6 +774,16 @@ dependencies = [
"libc", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.15" version = "0.2.15"
@ -958,7 +1002,6 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"hyper", "hyper",
"hyper-rustls",
"ipnet", "ipnet",
"js-sys", "js-sys",
"lazy_static", "lazy_static",
@ -966,14 +1009,10 @@ dependencies = [
"mime", "mime",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-native-certs",
"rustls-pemfile 1.0.1",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"tokio", "tokio",
"tokio-rustls",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
@ -1133,6 +1172,17 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.144" version = "1.0.144"
@ -1251,6 +1301,25 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 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]] [[package]]
name = "syn" name = "syn"
version = "1.0.99" version = "1.0.99"
@ -1400,12 +1469,8 @@ checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls",
"rustls-native-certs",
"tokio", "tokio",
"tokio-rustls",
"tungstenite", "tungstenite",
"webpki",
] ]
[[package]] [[package]]
@ -1422,6 +1487,16 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tokio_modbus-winets"
version = "0.1.0"
dependencies = [
"async-trait",
"sungrow-winets",
"tokio-modbus",
"tracing",
]
[[package]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.2" version = "0.3.2"
@ -1505,12 +1580,10 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand", "rand",
"rustls",
"sha-1", "sha-1",
"thiserror", "thiserror",
"url", "url",
"utf-8", "utf-8",
"webpki",
] ]
[[package]] [[package]]

View File

@ -1,30 +1,6 @@
[package] [workspace]
name = "modbus-mqtt" members = [
version = "0.1.0" "modbus-mqtt",
edition = "2021" "sungrow-winets",
"tokio_modbus-winets",
# 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"

View File

@ -33,4 +33,67 @@ prefix/connection/<connection>/monitor[/opt-name] <- {
## Similar projects ## Similar projects
* https://github.com/Instathings/modbus2mqtt * https://github.com/Instathings/modbus2mqtt
* https://github.com/TenySmart/ModbusTCP2MQTT - Sungrow inverter specific * 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"
}]
}
```

View File

@ -0,0 +1,27 @@
[package]
name = "modbus-mqtt"
version = "0.1.0"
edition = "2021"
authors = ["Bo Jeanes <me@bjeanes.com>"]
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"

View File

@ -0,0 +1,4 @@
pub struct Connection {
// connect: Connect,
context: tokio_modbus::client::Context,
}

View File

@ -2,7 +2,7 @@ use rumqttc::{self, AsyncClient, Event, Incoming, LastWill, MqttOptions, Publish
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
use std::{collections::HashMap, time::Duration}; 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 tokio_modbus::prelude::*;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
@ -77,6 +77,7 @@ async fn main() {
enum DispatchCommand { enum DispatchCommand {
Publish { topic: String, payload: Vec<u8> }, Publish { topic: String, payload: Vec<u8> },
} }
#[tracing::instrument(level = "debug")]
async fn mqtt_dispatcher( async fn mqtt_dispatcher(
mut options: MqttOptions, mut options: MqttOptions,
prefix: String, prefix: String,
@ -179,6 +180,7 @@ enum RegistryCommand {
type RegistryDb = HashMap<ConnectionId, tokio::task::JoinHandle<()>>; type RegistryDb = HashMap<ConnectionId, tokio::task::JoinHandle<()>>;
#[tracing::instrument(level = "debug")]
async fn connection_registry( async fn connection_registry(
prefix: String, prefix: String,
dispatcher: mpsc::Sender<DispatchCommand>, dispatcher: mpsc::Sender<DispatchCommand>,
@ -244,7 +246,7 @@ async fn handle_connect(
let mut modbus = match connect.settings { let mut modbus = match connect.settings {
ModbusProto::SungrowWiNetS { ref host } => { ModbusProto::SungrowWiNetS { ref host } => {
modbus::sungrow::winets::connect_slave(host, unit) tokio_modbus_winets::connect_slave(host, unit)
.await .await
.unwrap() .unwrap()
} }
@ -359,6 +361,7 @@ async fn handle_connect(
} }
} }
#[tracing::instrument(level = "debug")]
async fn watch_registers( async fn watch_registers(
read_type: ModbusReadType, read_type: ModbusReadType,
address_offset: i8, address_offset: i8,

View File

@ -208,7 +208,7 @@ pub struct RegisterParse {
pub value_type: RegisterValueType, pub value_type: RegisterValueType,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Register { pub struct Register {
pub address: u16, pub address: u16,

View File

@ -4,7 +4,6 @@ use serde::Serialize;
use self::config::{Register, RegisterValueType}; use self::config::{Register, RegisterValueType};
pub mod config; pub mod config;
pub mod sungrow;
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]

View File

@ -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<H>(host: H) -> Result<ModbusContext, Error>
where
H: Into<String>,
{
connect_slave(host, Slave(1)).await
}
pub async fn connect_slave<H>(host: H, slave: Slave) -> Result<ModbusContext, Error>
where
H: Into<String>,
{
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<dyn Client> = 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<crate::modbus::Unit>,
token: tokio::sync::watch::Receiver<Option<String>>,
// 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<Response, Error> {
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);
}
}
}

View File

@ -0,0 +1,30 @@
[package]
name = "sungrow-winets"
version = "0.1.0"
edition = "2021"
authors = ["Bo Jeanes <me@bjeanes.com>"]
[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"]

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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);
}
}

View File

@ -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(())
}

View File

@ -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<String> },
#[error(transparent)]
JSONError(#[from] serde_json::Error),
#[error("Expected attached data")]
ExpectedData,
#[error("No token")]
NoToken,
}
impl From<Error> 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<Device>,
}
const WS_PORT: u16 = 8082;
type Result<T> = std::result::Result<T, Error>;
impl Client {
pub async fn new<H>(host: H) -> Result<Self>
where
H: Into<String>,
{
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::<SungrowResult>(&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<H>(host: H, token: String) -> Result<Self>
where
H: Into<String>,
{
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<Vec<u16>> {
// 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<RunningState> {
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<T>(response: reqwest::Response) -> Result<T>
where
Result<T>: From<SungrowResult>,
{
let body = response.text().await?;
debug!(%body, "parsing");
let sg_result = serde_json::from_slice::<SungrowResult>(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<Vec<()>> // 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<Device> },
// 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<T> {
count: u16,
#[serde(rename = "list")]
items: Vec<T>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ResultData {
// TODO: custom deserializer into words
GetParam {
#[serde(deserialize_with = "words_from_string")]
param_value: Vec<u16>,
},
DeviceList(ResultList<Device>),
WebSocketMessage(WebSocketMessage),
// // String = name - http://<host>/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<ResultData, SungrowError>`?
// - 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://<host>/i18n/en_US.properties has the translations for messages (only ones which start with I18N_*)
message: Option<String>, // at least one result I saw (code = 200 at the time) had no message :\
#[serde(rename = "result_data")]
data: Option<ResultData>,
}
impl From<SungrowResult> for Result<Option<ResultData>> {
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<SungrowResult> for Result<ResultData> {
fn from(sg_result: SungrowResult) -> Self {
let data: Result<Option<ResultData>> = sg_result.into();
if let Some(data) = data? {
Ok(data)
} else {
Err(Error::ExpectedData)
}
}
}
impl From<SungrowResult> for Result<()> {
fn from(sg_result: SungrowResult) -> Self {
let data: Result<Option<ResultData>> = 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<Vec<u16>, D::Error>
where
D: serde::Deserializer<'de>,
{
StringOrVecToVec::with_separator(' ').into_deserializer()(deserializer).map(
|vec: Vec<String>| {
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::<Vec<u8>>()
.try_into()
.expect("we always have two elements, because of `chunks_exact`");
u16::from_be_bytes(bytes)
})
.collect::<Vec<u16>>()
},
)
}
#[test]
fn test_words_from_string() {
#[derive(serde::Deserialize, Debug)]
struct MyStruct {
#[serde(deserialize_with = "words_from_string")]
list: Vec<u16>,
}
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<Vec<u8>, 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<u8>,
}
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]);
}

View File

@ -0,0 +1,11 @@
[package]
name = "tokio_modbus-winets"
version = "0.1.0"
edition = "2021"
authors = ["Bo Jeanes <me@bjeanes.com>"]
[dependencies]
async-trait = "0.1.57"
sungrow-winets = { path = "../sungrow-winets" }
tokio-modbus = { version = "0.5.3", features = [] }
tracing = "0.1.36"

View File

@ -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<H>(host: H) -> Result<Context, Error>
where
H: Into<String>,
{
connect_slave(host, Slave(1)).await
}
pub async fn connect_slave<H>(host: H, slave: Slave) -> Result<Context, Error>
where
H: Into<String>,
{
let context = crate::service::connect_slave(host, slave).await?;
let client: Box<dyn Client> = Box::new(context);
Ok(Context::from(client))
}

View File

@ -0,0 +1,4 @@
pub mod client;
pub mod service;
pub use client::{connect, connect_slave};

View File

@ -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<H>(host: H, _slave: Slave) -> Result<Context, Error>
where
H: Into<String>,
{
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<Self, Error> {
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<Response, Error> {
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;
}
}