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
parent
2557d99d9c
commit
e3d4d024ce
|
@ -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]]
|
||||
|
|
36
Cargo.toml
36
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",
|
||||
]
|
||||
|
|
65
README.md
65
README.md
|
@ -33,4 +33,67 @@ prefix/connection/<connection>/monitor[/opt-name] <- {
|
|||
## Similar projects
|
||||
|
||||
* 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"
|
||||
}]
|
||||
}
|
||||
```
|
|
@ -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"
|
|
@ -0,0 +1,4 @@
|
|||
pub struct Connection {
|
||||
// connect: Connect,
|
||||
context: tokio_modbus::client::Context,
|
||||
}
|
|
@ -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<u8> },
|
||||
}
|
||||
#[tracing::instrument(level = "debug")]
|
||||
async fn mqtt_dispatcher(
|
||||
mut options: MqttOptions,
|
||||
prefix: String,
|
||||
|
@ -179,6 +180,7 @@ enum RegistryCommand {
|
|||
|
||||
type RegistryDb = HashMap<ConnectionId, tokio::task::JoinHandle<()>>;
|
||||
|
||||
#[tracing::instrument(level = "debug")]
|
||||
async fn connection_registry(
|
||||
prefix: String,
|
||||
dispatcher: mpsc::Sender<DispatchCommand>,
|
||||
|
@ -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,
|
|
@ -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,
|
||||
|
|
@ -4,7 +4,6 @@ use serde::Serialize;
|
|||
use self::config::{Register, RegisterValueType};
|
||||
|
||||
pub mod config;
|
||||
pub mod sungrow;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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"
|
|
@ -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))
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
pub mod client;
|
||||
pub mod service;
|
||||
|
||||
pub use client::{connect, connect_slave};
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue