diff --git a/Cargo.lock b/Cargo.lock index 6db565f..9afcfe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,9 +231,9 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "crc" -version = "3.0.1" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" dependencies = [ "crc-catalog", ] @@ -345,6 +345,13 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "gateway-client" +version = "0.1.0" +dependencies = [ + "tokio-modbus", +] + [[package]] name = "gimli" version = "0.28.1" @@ -1134,6 +1141,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1181,9 +1208,9 @@ dependencies = [ [[package]] name = "tokio-modbus" -version = "0.11.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d99561deee858c4d60b0cb170b67e715f7688c5923c371d2d30c9773ef7d2d8" +checksum = "033b1b9843d693c3543e6b9c656a566ea45d2564e72ad5447e83233b9e2f3fe1" dependencies = [ "async-trait", "byteorder", @@ -1192,6 +1219,7 @@ dependencies = [ "futures-util", "log", "smallvec", + "thiserror", "tokio", "tokio-util", ] @@ -1292,13 +1320,13 @@ dependencies = [ "anyhow", "clap", "colored", + "gateway-client", "hftwo", "open", "reqwest", "serde", "serde_json", "tokio", - "tokio-modbus", "uftwo", ] diff --git a/Cargo.toml b/Cargo.toml index fd009d2..85a1b42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,13 @@ version = "0.0.5" edition = "2021" [dependencies] +gateway-client = { path = "gateway-client" } hftwo = "0.1.2" uftwo = "0.1.0" anyhow = "1.0.81" clap = { version = "4.5.2", features = ["derive"] } open = "5.1.2" tokio = { version = "1.37.0", features = ["full", "net"] } -tokio-modbus = "0.11.0" reqwest = { version = "0.12.4", features = ["json"] } serde_json = "1.0.117" serde = { version = "1.0.202", features = ["derive"] } @@ -26,6 +26,9 @@ colored = "2.1.0" inherits = "release" lto = "thin" +[workspace] +members = ["gateway-client"] + # Config for 'cargo dist' [workspace.metadata.dist] # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) diff --git a/gateway-client/Cargo.toml b/gateway-client/Cargo.toml new file mode 100644 index 0000000..8511092 --- /dev/null +++ b/gateway-client/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "gateway-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio-modbus = "0.14.0" diff --git a/gateway-client/src/lib.rs b/gateway-client/src/lib.rs new file mode 100644 index 0000000..030e277 --- /dev/null +++ b/gateway-client/src/lib.rs @@ -0,0 +1,192 @@ +use std::{ + fmt::Display, + net::{IpAddr, Ipv4Addr, SocketAddr}, +}; +use tokio_modbus::{ + client::{tcp::connect, Reader, Writer}, + slave::SlaveContext, + Result as ModbusResult, Slave, +}; + +#[derive(Debug)] +pub struct Client { + modbus: tokio_modbus::client::Context, +} + +impl Client { + pub async fn connect(ip: IpAddr) -> std::io::Result { + let mut modbus = connect(SocketAddr::new(ip, 502)).await?; + modbus.set_slave(Slave(1)); + + Ok(Self { modbus }) + } + + /// Get device identifier. + pub async fn device_identifier( + &mut self, + ) -> ModbusResult { + self.modbus + .read_holding_registers(0, 1) + .await + .map(|v| v.map(|v| v[0]).map(DeviceIdentifier::from)) + } + + /// Restart the gateway gracefully + pub async fn restart(&mut self) -> ModbusResult<()> { + self.modbus.write_single_coil(1, true).await + } + + /// Reset the gateway to factory defaults + pub async fn reset(&mut self) -> ModbusResult<()> { + self.modbus.write_single_coil(2, true).await + } + + /// Get hardware version. + pub async fn hardware_version(&mut self) -> ModbusResult { + let version = self.modbus.read_holding_registers(1, 3).await?.unwrap(); + Ok(Ok(Version { + major: version[0], + minor: version[1], + patch: version[2], + })) + } + + /// Get firmware version. + pub async fn firmware_version(&mut self) -> ModbusResult { + let version = self.modbus.read_holding_registers(4, 3).await?.unwrap(); + Ok(Ok(Version { + major: version[0], + minor: version[1], + patch: version[2], + })) + } + + /// Get serial number. + pub async fn serial(&mut self) -> ModbusResult { + let serial = self.modbus.read_holding_registers(7, 2).await?.unwrap(); + + Ok(Ok(Serial { + year: serial[0].to_le_bytes()[1], + week: serial[0].to_le_bytes()[0], + seq: serial[1], + })) + } + + /// Get DHCP enabled. + pub async fn dhcp(&mut self) -> ModbusResult { + let enabled = self.modbus.read_coils(1001, 1).await?.unwrap(); + Ok(Ok(enabled[0])) + } + + /// Set DHCP enabled. + pub async fn set_dhcp(&mut self, enabled: bool) -> ModbusResult<()> { + self.modbus.write_single_coil(1001, enabled).await + } + + /// Get the configured IPv4 address. + pub async fn ipv4_address(&mut self) -> ModbusResult { + let address = self.modbus.read_input_registers(1001, 4).await?.unwrap(); + Ok(Ok(Ipv4Addr::new( + address[0] as u8, + address[1] as u8, + address[2] as u8, + address[3] as u8, + ))) + } + + /// Set the IPv4 address. + pub async fn set_ipv4_address(&mut self, ip: Ipv4Addr) -> ModbusResult<()> { + let words = ip.octets().map(|o| o as u16); + self.modbus.write_multiple_registers(1001, &words).await + } + + /// Get CAN bus receive error count. + pub async fn canbus_receive_error_count(&mut self) -> ModbusResult { + let count = self.modbus.read_input_registers(2001, 1).await?.unwrap(); + Ok(Ok(count[0])) + } + + /// Get CAN bus transmit error count. + pub async fn canbus_transmit_error_count(&mut self) -> ModbusResult { + let count = self.modbus.read_input_registers(2002, 1).await?.unwrap(); + Ok(Ok(count[0])) + } + + /// Get the CAN bus nominal rate in bits per second. + pub async fn canbus_bitrate_nominal(&mut self) -> ModbusResult { + let rate = self.modbus.read_holding_registers(2001, 1).await?.unwrap(); + Ok(Ok(rate[0] as u32 * 100)) + } + + /// Set the CAN bus nominal rate in bits per second. + pub async fn set_canbus_bitrate_nominal( + &mut self, + rate: u32, + ) -> ModbusResult<()> { + let rate = (rate / 100) as u16; + self.modbus.write_single_register(2001, rate).await + } + + /// Get the CAN bus data rate in bits per second. + pub async fn canbus_bitrate_data(&mut self) -> ModbusResult { + let rate = self.modbus.read_holding_registers(2001, 1).await?.unwrap(); + Ok(Ok(rate[0] as u32 * 100)) + } + + /// Set the CAN bus data rate in bits per second. + pub async fn set_canbus_bitrate_data( + &mut self, + rate: u32, + ) -> ModbusResult<()> { + let rate = (rate / 100) as u16; + self.modbus.write_single_register(2001, rate).await + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Serial { + pub year: u8, + pub week: u8, + pub seq: u16, +} + +impl Display for Serial { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:02}{:02}-{:04X}", self.year, self.week, self.seq) + } +} + +#[derive(Debug)] +pub struct Version { + pub major: u16, + pub minor: u16, + pub patch: u16, +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "v{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// Magic numbers used to identify Gateway types. +/// Must be u16 to fit in one Modbus register. +#[derive(Debug, Clone, Copy)] +pub enum DeviceIdentifier { + /// "FD" like CAN FD + CanFd, + /// "RS" like RS-232/RS-485 + Serial, + /// Unkown but possibly valid identifier. + Unknown(u16), +} + +impl From for DeviceIdentifier { + fn from(value: u16) -> Self { + match value { + 0x4644 => Self::CanFd, + 0x5253 => Self::Serial, + _ => Self::Unknown(value), + } + } +} diff --git a/src/gateway/config.rs b/src/gateway/config.rs new file mode 100644 index 0000000..bf6436d --- /dev/null +++ b/src/gateway/config.rs @@ -0,0 +1,161 @@ +use clap::{error, Parser, Subcommand}; +use colored::Colorize; +use gateway_client::{Client, DeviceIdentifier}; +use std::net::Ipv4Addr; + +use crate::write_with_header; + +#[derive(Subcommand)] +enum Commands { + /// DHCPv4 enable/disable. + Dhcp(Dhcp), + /// IPv4 address. + Ipv4(Ipv4), + /// CAN Bus bitrate. + CanBitrate(CanBitrate), +} + +#[derive(Parser)] +struct Dhcp { + // Enable or disable DHCP. + #[arg(value_parser = parse_enable)] + enable: Option, +} + +#[derive(Parser)] +struct Ipv4 { + /// Set the static IPv4 address. + ip: Option, +} + +#[derive(Parser)] +struct CanBitrate { + /// Set the nominal data rate in bits per second. + nominal: Option, + /// Set the data bitrate in bits per second. (optional) + data: Option, +} + +#[derive(Parser)] +pub struct Cmd { + #[clap(subcommand)] + subcommand: Commands, +} + +impl Cmd { + pub async fn run( + self, + mut output: impl std::io::Write, + ip: std::net::IpAddr, + ) -> anyhow::Result<()> { + let mut client = Client::connect(ip).await?; + + match self.subcommand { + Commands::Dhcp(dhcp) => { + if let Some(enable) = dhcp.enable { + client.set_dhcp(enable).await??; + writeln!(output, "Done")?; + } else { + writeln!(output, "{}", client.dhcp().await??)?; + } + Ok(()) + } + Commands::Ipv4(ipv4) => { + if let Some(ip) = ipv4.ip { + client.set_ipv4_address(ip).await??; + writeln!(output, "Done")?; + } else { + writeln!(output, "{}", client.ipv4_address().await??)?; + } + + Ok(()) + } + Commands::CanBitrate(can_bitrate) => { + match client.device_identifier().await?? { + DeviceIdentifier::CanFd => {} + _ => { + return Err(anyhow::Error::msg( + "Device does not have a CAN interface.", + )); + } + } + + if let Some(nominal) = can_bitrate.nominal { + // use same as nominal if not specified + let data = can_bitrate.data.unwrap_or(nominal); + + if nominal < 10_000 { + return Err(anyhow::Error::msg( + "Nominal bitrate too low.", + )); + } + + if nominal > 5_000_000 { + return Err(anyhow::Error::msg( + "Nominal bitrate too high.", + )); + } + + if data < 10_000 { + return Err(anyhow::Error::msg( + "Data bitrate too low.", + )); + } + + if data > 5_000_000 { + return Err(anyhow::Error::msg( + "Data bitrate too high.", + )); + } + + client.set_canbus_bitrate_nominal(data).await??; + client.set_canbus_bitrate_data(data).await??; + + writeln!(output, "Done")?; + } else { + let nominal = client.canbus_bitrate_nominal().await??; + let data = client.canbus_bitrate_data().await??; + + write_with_header( + &mut output, + "Nominal bitrate".green(), + &format!("{} bit/s", nominal), + ); + + write_with_header( + &mut output, + "Data bitrate".green(), + &format!("{} bit/s", data), + ); + } + + Ok(()) + } + } + } +} + +/// A more general parser for boolean values such as "enable", "disable", "on" +/// and "off" as well as "true" and "false". +fn parse_enable(arg: &str) -> Result { + match arg { + "enable" => Ok(true), + "true" => Ok(true), + "on" => Ok(true), + "disable" => Ok(false), + "false" => Ok(false), + "off" => Ok(false), + _ => Err(error::Error::new(error::ErrorKind::InvalidValue)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_parse() { + assert_eq!(parse_enable("enable").unwrap(), true); + assert_eq!(parse_enable("disable").unwrap(), false); + } +} diff --git a/src/gateway/manifest.rs b/src/gateway/manifest.rs index 204e46e..9459154 100644 --- a/src/gateway/manifest.rs +++ b/src/gateway/manifest.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; /// /// Only deserializes the `schema` field. #[derive(Debug, Deserialize)] +#[allow(unused)] pub struct ManifestSchema { pub schema: String, } @@ -25,6 +26,7 @@ pub struct ManifestSchema { /// } /// ``` #[derive(Debug, Deserialize)] +#[allow(unused)] pub struct Manifest { /// Schema version. pub schema: String, @@ -42,6 +44,7 @@ pub struct Manifest { /// so devices can step-up to the latest firmware without issues due to /// breaking changes. #[derive(Debug, Deserialize)] +#[allow(unused)] pub struct FirmwareBinary { /// File link. pub file: String, diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 5ef6cdd..a2be9b3 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1,18 +1,12 @@ +mod config; mod manifest; +mod reset; mod restart; mod status; mod update; use clap::{Parser, Subcommand}; -use std::{ - net::{IpAddr, SocketAddr}, - path::PathBuf, -}; -use tokio_modbus::{ - client::{tcp::connect, Context}, - slave::SlaveContext, - Slave, -}; +use std::{net::IpAddr, path::PathBuf}; #[derive(Subcommand)] pub enum Commands { @@ -20,8 +14,12 @@ pub enum Commands { Status, /// Update firmware Update(UpdateOptions), - /// Command the device to restart + /// Reset all configuration + Reset, + /// Restart Restart, + /// Read and write configuration + Config(config::Cmd), } #[derive(Parser)] @@ -42,7 +40,9 @@ impl Cmd { Commands::Update(options) => { update::command(output, options, self.ip).await } + Commands::Reset => reset::command(output, self.ip).await, Commands::Restart => restart::command(output, self.ip).await, + Commands::Config(command) => command.run(output, self.ip).await, } } } @@ -56,9 +56,3 @@ pub struct UpdateOptions { #[clap(long)] version: Option, } - -pub async fn connect_modbus(ip: IpAddr) -> Result { - let mut ctx = connect(SocketAddr::new(ip, 502)).await?; - ctx.set_slave(Slave(1)); - Ok(ctx) -} diff --git a/src/gateway/reset.rs b/src/gateway/reset.rs new file mode 100644 index 0000000..3ee7268 --- /dev/null +++ b/src/gateway/reset.rs @@ -0,0 +1,18 @@ +use crate::write_with_header; +use colored::Colorize; +use std::{net::IpAddr, time::Duration}; +use tokio::time::timeout; + +#[allow(unused_variables, unused_mut)] +pub async fn command( + mut output: impl std::io::Write, + ip: IpAddr, +) -> anyhow::Result<()> { + let mut client = gateway_client::Client::connect(ip).await?; + + write_with_header(&mut output, "Resetting".green(), " "); + let _ = timeout(Duration::from_secs(1), client.reset()).await; + write_with_header(&mut output, "Done".green(), " "); + + Ok(()) +} diff --git a/src/gateway/restart.rs b/src/gateway/restart.rs index a4f1a49..21a77fa 100644 --- a/src/gateway/restart.rs +++ b/src/gateway/restart.rs @@ -1,26 +1,17 @@ use crate::write_with_header; use colored::Colorize; use std::{net::IpAddr, time::Duration}; -use tokio_modbus::client::Writer; - -use super::connect_modbus; +use tokio::time::timeout; #[allow(unused_variables, unused_mut)] pub async fn command( mut output: impl std::io::Write, ip: IpAddr, ) -> anyhow::Result<()> { - let mut modbus = connect_modbus(ip).await?; + let mut client = gateway_client::Client::connect(ip).await?; write_with_header(&mut output, "Restarting".green(), " "); - - // write reset coil - let _ = tokio::time::timeout( - Duration::from_secs(1), - modbus.write_single_coil(1, true), - ) - .await; - + let _ = timeout(Duration::from_secs(1), client.restart()).await; write_with_header(&mut output, "Done".green(), " "); Ok(()) diff --git a/src/gateway/status.rs b/src/gateway/status.rs index 2e51f99..d0945d9 100644 --- a/src/gateway/status.rs +++ b/src/gateway/status.rs @@ -1,35 +1,31 @@ -use super::connect_modbus; use crate::write_with_header; use colored::Colorize; use std::{net::IpAddr, time::Instant}; -use tokio_modbus::client::Reader; pub async fn command( mut output: impl std::io::Write, ip: IpAddr, ) -> anyhow::Result<()> { - let mut modbus = connect_modbus(ip).await?; + let mut client = gateway_client::Client::connect(ip).await?; let start = Instant::now(); - let hardware_version = modbus.read_holding_registers(1, 3).await?; + write_with_header( + &mut output, + "Serial".green(), + &format!("{}", client.serial().await??), + ); + write_with_header( &mut output, "Hardware Version".green(), - &format!( - "v{}.{}.{}", - hardware_version[0], hardware_version[1], hardware_version[2] - ), + &format!("{}", client.hardware_version().await??), ); - let firmware_version = modbus.read_holding_registers(4, 3).await?; write_with_header( &mut output, "Firmware Version".green(), - &format!( - "v{}.{}.{}", - firmware_version[0], firmware_version[1], firmware_version[2], - ), + &format!("{}", client.firmware_version().await??,), ); writeln!(output, "Got status in {:?}", start.elapsed())?; diff --git a/src/gateway/update.rs b/src/gateway/update.rs index a5f85f1..c742a24 100644 --- a/src/gateway/update.rs +++ b/src/gateway/update.rs @@ -93,7 +93,7 @@ pub async fn command( let meta = file.metadata().await?; let mut contents = vec![0; meta.len() as usize]; - file.read(&mut contents).await?; + let _ = file.read(&mut contents).await?; upgrade_firmware(output, ip, &contents).await?; } else { @@ -128,11 +128,7 @@ pub async fn command( } }; - write_with_header( - &mut output, - "Version".green(), - &format!("{}", firmware.0), - ); + write_with_header(&mut output, "Version".green(), firmware.0); let binary = reqwest::get(&firmware.1.file).await?.bytes().await?;