From 8556a7ca0ef999d7e474816880d65799ea45c86c Mon Sep 17 00:00:00 2001 From: Aram Peres Date: Sat, 31 Jul 2021 04:18:39 -0400 Subject: [PATCH] Implement rupsc, clone of upsc (#5) Fixes #4 --- .cargo/config | 11 +++ .github/workflows/ci.yml | 9 +- Cargo.toml | 3 +- LICENSE | 2 +- README.md | 11 ++- nut-client/examples/blocking.rs | 1 + nut-client/src/blocking/mod.rs | 137 +++++++++++++++++++---------- nut-client/src/cmd.rs | 102 ++++++++++++++++++++-- nut-client/src/config.rs | 20 ++++- nut-client/src/var.rs | 54 ++++++++---- rupsc/Cargo.toml | 21 +++++ rupsc/LICENSE | 21 +++++ rupsc/README.md | 65 ++++++++++++++ rupsc/src/cmd.rs | 66 ++++++++++++++ rupsc/src/main.rs | 85 ++++++++++++++++++ rupsc/src/parser.rs | 150 ++++++++++++++++++++++++++++++++ 16 files changed, 676 insertions(+), 82 deletions(-) create mode 100644 .cargo/config create mode 100644 rupsc/Cargo.toml create mode 100644 rupsc/LICENSE create mode 100644 rupsc/README.md create mode 100644 rupsc/src/cmd.rs create mode 100644 rupsc/src/main.rs create mode 100644 rupsc/src/parser.rs diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 0000000..4d851bc --- /dev/null +++ b/.cargo/config @@ -0,0 +1,11 @@ +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" + +[target.arm-unknown-linux-gnueabihf] +linker = "arm-linux-gnueabihf-gcc" + +[target.armv7-unknown-linux-gnueabihf] +linker = "arm-linux-gnueabihf-gcc" + +[target.x86_64-unknown-linux-gnu] +linker = "x86_64-linux-gnu-gcc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e511880..39bdb91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: strategy: matrix: target: + - x86_64-unknown-linux-gnu - aarch64-unknown-linux-gnu - arm-unknown-linux-gnueabihf - armv7-unknown-linux-gnueabihf @@ -54,7 +55,13 @@ jobs: uses: actions-rs/cargo@v1 with: command: build - args: --target ${{ matrix.target }} + args: --target ${{ matrix.target }} --release + + - name: Archive rupsc binary + uses: actions/upload-artifact@v2 + with: + name: rupsc-${{ matrix.target }} + path: target/${{ matrix.target }}/release/rupsc test: name: Test Suite diff --git a/Cargo.toml b/Cargo.toml index 044aa61..4ceb55c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] members = [ - "nut-client" + "nut-client", + "rupsc" ] diff --git a/LICENSE b/LICENSE index 5ee4a0c..edeffdb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Aram Peres +Copyright (c) 2020-2021 Aram Peres Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b964161..a70e849 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,22 @@ A [Network UPS Tools](https://github.com/networkupstools/nut) (NUT) client library for Rust. - Connect to `upsd`/`nut-server` using TCP -- Login with with username and password +- Login with username and password - List UPS devices - List variables for a UPS device ## ⚠️ Safety Goggles Required ⚠️ -Do not use this library with critical UPS devices. This library is in early development and I cannot -guarantee that it won't mess up your UPS configurations, and potentially cause catastrophic failure to your hardware. +Do not use this library with critical UPS devices. This library is in early development, and I cannot guarantee that it +won't mess up your UPS configurations, and potentially cause catastrophic failure to your hardware. Be careful and stay safe! ## Example -Check out the `examples` directory for more advanced examples. +The [rupsc](https://github.com/aramperes/nut-client-rs/tree/master/rupsc) +command-line utility is a clone of NUT's built-in [upsc](https://networkupstools.org/docs/man/upsc.html) command-line +utility, written using this library. ```rust use std::env; @@ -45,6 +47,7 @@ fn main() -> nut_client::Result<()> { let config = ConfigBuilder::new() .with_host(Host::Tcp(addr)) .with_auth(auth) + .with_debug(false) // Turn this on for debugging network chatter .build(); let mut conn = Connection::new(config)?; diff --git a/nut-client/examples/blocking.rs b/nut-client/examples/blocking.rs index 4256f71..06cef65 100644 --- a/nut-client/examples/blocking.rs +++ b/nut-client/examples/blocking.rs @@ -19,6 +19,7 @@ fn main() -> nut_client::Result<()> { let config = ConfigBuilder::new() .with_host(Host::Tcp(addr)) .with_auth(auth) + .with_debug(false) // Turn this on for debugging network chatter .build(); let mut conn = Connection::new(config)?; diff --git a/nut-client/src/blocking/mod.rs b/nut-client/src/blocking/mod.rs index 436f598..b5d5d7a 100644 --- a/nut-client/src/blocking/mod.rs +++ b/nut-client/src/blocking/mod.rs @@ -3,7 +3,7 @@ use std::io::{BufRead, BufReader, Write}; use std::net::{SocketAddr, TcpStream}; use crate::cmd::{Command, Response}; -use crate::{ClientError, Config, Host, NutError, Variable}; +use crate::{Config, Host, NutError, Variable}; /// A blocking NUT client connection. pub enum Connection { @@ -28,6 +28,13 @@ impl Connection { } } + /// Queries a list of client IP addresses connected to the given device. + pub fn list_clients(&mut self, ups_name: &str) -> crate::Result> { + match self { + Self::Tcp(conn) => conn.list_clients(ups_name), + } + } + /// Queries the list of variables for a UPS device. pub fn list_vars(&mut self, ups_name: &str) -> crate::Result> { match self { @@ -38,6 +45,16 @@ impl Connection { .collect()), } } + + /// Queries one variable for a UPS device. + pub fn get_var(&mut self, ups_name: &str, variable: &str) -> crate::Result { + match self { + Self::Tcp(conn) => { + let var = conn.get_var(ups_name, variable)?; + Ok(Variable::parse(var.0.as_str(), var.1)) + } + } + } } /// A blocking TCP NUT client connection. @@ -62,49 +79,88 @@ impl TcpConnection { fn login(&mut self) -> crate::Result<()> { if let Some(auth) = &self.config.auth { // Pass username and check for 'OK' - Self::write_cmd(&mut self.tcp_stream, Command::SetUsername(&auth.username))?; - Self::read_response(&mut self.tcp_stream)?.expect_ok()?; + Self::write_cmd( + &mut self.tcp_stream, + Command::SetUsername(&auth.username), + self.config.debug, + )?; + Self::read_response(&mut self.tcp_stream, self.config.debug)?.expect_ok()?; // Pass password and check for 'OK' if let Some(password) = &auth.password { - Self::write_cmd(&mut self.tcp_stream, Command::SetPassword(password))?; - Self::read_response(&mut self.tcp_stream)?.expect_ok()?; + Self::write_cmd( + &mut self.tcp_stream, + Command::SetPassword(password), + self.config.debug, + )?; + Self::read_response(&mut self.tcp_stream, self.config.debug)?.expect_ok()?; } } Ok(()) } fn list_ups(&mut self) -> crate::Result> { - Self::write_cmd(&mut self.tcp_stream, Command::List(&["UPS"]))?; - let list = Self::read_list(&mut self.tcp_stream, &["UPS"])?; + Self::write_cmd( + &mut self.tcp_stream, + Command::List(&["UPS"]), + self.config.debug, + )?; + let list = Self::read_list(&mut self.tcp_stream, &["UPS"], self.config.debug)?; - Ok(list - .into_iter() - .map(|mut row| (row.remove(0), row.remove(0))) - .collect()) + list.into_iter().map(|row| row.expect_ups()).collect() + } + + fn list_clients(&mut self, ups_name: &str) -> crate::Result> { + let query = &["CLIENT", ups_name]; + Self::write_cmd( + &mut self.tcp_stream, + Command::List(query), + self.config.debug, + )?; + let list = Self::read_list(&mut self.tcp_stream, query, self.config.debug)?; + + list.into_iter().map(|row| row.expect_client()).collect() } fn list_vars(&mut self, ups_name: &str) -> crate::Result> { let query = &["VAR", ups_name]; - Self::write_cmd(&mut self.tcp_stream, Command::List(query))?; - let list = Self::read_list(&mut self.tcp_stream, query)?; + Self::write_cmd( + &mut self.tcp_stream, + Command::List(query), + self.config.debug, + )?; + let list = Self::read_list(&mut self.tcp_stream, query, self.config.debug)?; - Ok(list - .into_iter() - .map(|mut row| (row.remove(0), row.remove(0))) - .collect()) + list.into_iter().map(|row| row.expect_var()).collect() } - fn write_cmd(stream: &mut TcpStream, line: Command) -> crate::Result<()> { + fn get_var(&mut self, ups_name: &str, variable: &str) -> crate::Result<(String, String)> { + let query = &["VAR", ups_name, variable]; + Self::write_cmd(&mut self.tcp_stream, Command::Get(query), self.config.debug)?; + + let resp = Self::read_response(&mut self.tcp_stream, self.config.debug)?; + resp.expect_var() + } + + fn write_cmd(stream: &mut TcpStream, line: Command, debug: bool) -> crate::Result<()> { let line = format!("{}\n", line); + if debug { + eprint!("DEBUG -> {}", line); + } stream.write_all(line.as_bytes())?; stream.flush()?; Ok(()) } - fn parse_line(reader: &mut BufReader<&mut TcpStream>) -> crate::Result> { + fn parse_line( + reader: &mut BufReader<&mut TcpStream>, + debug: bool, + ) -> crate::Result> { let mut raw = String::new(); reader.read_line(&mut raw)?; + if debug { + eprint!("DEBUG <- {}", raw); + } raw = raw[..raw.len() - 1].to_string(); // Strip off \n // Parse args by splitting whitespace, minding quotes for args with multiple words @@ -114,46 +170,35 @@ impl TcpConnection { Ok(args) } - fn read_response(stream: &mut TcpStream) -> crate::Result { + fn read_response(stream: &mut TcpStream, debug: bool) -> crate::Result { let mut reader = io::BufReader::new(stream); - let args = Self::parse_line(&mut reader)?; + let args = Self::parse_line(&mut reader, debug)?; Response::from_args(args) } - fn read_list(stream: &mut TcpStream, query: &[&str]) -> crate::Result>> { + fn read_list( + stream: &mut TcpStream, + query: &[&str], + debug: bool, + ) -> crate::Result> { let mut reader = io::BufReader::new(stream); - let args = Self::parse_line(&mut reader)?; + let args = Self::parse_line(&mut reader, debug)?; Response::from_args(args)?.expect_begin_list(query)?; - let mut lines: Vec> = Vec::new(); + let mut lines: Vec = Vec::new(); loop { - let mut args = Self::parse_line(&mut reader)?; - let resp = Response::from_args(args.clone()); + let args = Self::parse_line(&mut reader, debug)?; + let resp = Response::from_args(args)?; - if let Ok(resp) = resp { - resp.expect_end_list(query)?; - break; - } else { - let err = resp.unwrap_err(); - if let ClientError::Nut(err) = err { - if let NutError::UnknownResponseType(_) = err { - // Likely an item entry, let's check... - if args.len() < query.len() || &args[0..query.len()] != query { - return Err(ClientError::Nut(err)); - } else { - let args = args.drain(query.len()..).collect(); - lines.push(args); - continue; - } - } else { - return Err(ClientError::Nut(err)); - } - } else { - return Err(err); + match resp { + Response::EndList(_) => { + break; } + _ => lines.push(resp), } } + Ok(lines) } } diff --git a/nut-client/src/cmd.rs b/nut-client/src/cmd.rs index 0189d00..45b92d1 100644 --- a/nut-client/src/cmd.rs +++ b/nut-client/src/cmd.rs @@ -1,9 +1,10 @@ use core::fmt; -use crate::NutError; +use crate::{ClientError, NutError}; #[derive(Debug, Clone)] pub enum Command<'a> { + Get(&'a [&'a str]), /// Passes the login username. SetUsername(&'a str), /// Passes the login password. @@ -16,6 +17,7 @@ impl<'a> Command<'a> { /// The network identifier of the command. pub fn name(&self) -> &'static str { match self { + Self::Get(_) => "GET", Self::SetUsername(_) => "USERNAME", Self::SetPassword(_) => "PASSWORD", Self::List(_) => "LIST", @@ -25,6 +27,7 @@ impl<'a> Command<'a> { /// The arguments of the command to serialize. pub fn args(&self) -> Vec<&str> { match self { + Self::Get(cmd) => cmd.to_vec(), Self::SetUsername(username) => vec![username], Self::SetPassword(password) => vec![password], Self::List(query) => query.to_vec(), @@ -48,6 +51,18 @@ pub enum Response { BeginList(String), /// Marks the end of a list response. EndList(String), + /// A variable (VAR) response. + /// + /// Params: (var name, var value) + Var(String, String), + /// A UPS (UPS) response. + /// + /// Params: (device name, device description) + Ups(String, String), + /// A client (CLIENT) response. + /// + /// Params: (client IP) + Client(String), } impl Response { @@ -109,6 +124,64 @@ impl Response { } } } + "VAR" => { + let _var_device = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified VAR device name in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + let var_name = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified VAR name in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + let var_value = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified VAR value in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + Ok(Response::Var(var_name, var_value)) + } + "UPS" => { + let name = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified UPS name in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + let description = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified UPS description in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + Ok(Response::Ups(name, description)) + } + "CLIENT" => { + let _device = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified CLIENT device in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + let ip_address = if args.is_empty() { + Err(ClientError::from(NutError::Generic( + "Unspecified CLIENT IP in response".into(), + ))) + } else { + Ok(args.remove(0)) + }?; + Ok(Response::Client(ip_address)) + } _ => Err(NutError::UnknownResponseType(cmd_name).into()), } } @@ -133,14 +206,25 @@ impl Response { } } - pub fn expect_end_list(self, expected_args: &[&str]) -> crate::Result { - let expected_args = shell_words::join(expected_args); - if let Self::EndList(args) = &self { - if &expected_args == args { - Ok(self) - } else { - Err(NutError::UnexpectedResponse.into()) - } + pub fn expect_var(&self) -> crate::Result<(String, String)> { + if let Self::Var(name, value) = &self { + Ok((name.to_owned(), value.to_owned())) + } else { + Err(NutError::UnexpectedResponse.into()) + } + } + + pub fn expect_ups(&self) -> crate::Result<(String, String)> { + if let Self::Ups(name, description) = &self { + Ok((name.to_owned(), description.to_owned())) + } else { + Err(NutError::UnexpectedResponse.into()) + } + } + + pub fn expect_client(&self) -> crate::Result { + if let Self::Client(client_ip) = &self { + Ok(client_ip.to_owned()) } else { Err(NutError::UnexpectedResponse.into()) } diff --git a/nut-client/src/config.rs b/nut-client/src/config.rs index b21cf29..89d6d0b 100644 --- a/nut-client/src/config.rs +++ b/nut-client/src/config.rs @@ -12,7 +12,7 @@ pub enum Host { impl Default for Host { fn default() -> Self { - let addr = (String::from("localhost"), 3493) + let addr = (String::from("127.0.0.1"), 3493) .to_socket_addrs() .expect("Failed to create local UPS socket address. This is a bug.") .next() @@ -21,6 +21,12 @@ impl Default for Host { } } +impl From for Host { + fn from(addr: SocketAddr) -> Self { + Self::Tcp(addr) + } +} + /// An authentication mechanism. #[derive(Clone)] pub struct Auth { @@ -52,15 +58,17 @@ pub struct Config { pub(crate) host: Host, pub(crate) auth: Option, pub(crate) timeout: Duration, + pub(crate) debug: bool, } impl Config { /// Creates a connection configuration. - pub fn new(host: Host, auth: Option, timeout: Duration) -> Self { + pub fn new(host: Host, auth: Option, timeout: Duration, debug: bool) -> Self { Config { host, auth, timeout, + debug, } } } @@ -71,6 +79,7 @@ pub struct ConfigBuilder { host: Option, auth: Option, timeout: Option, + debug: Option, } impl ConfigBuilder { @@ -98,12 +107,19 @@ impl ConfigBuilder { self } + /// Enables debugging network calls by printing to stderr. + pub fn with_debug(mut self, debug: bool) -> Self { + self.debug = Some(debug); + self + } + /// Builds the configuration with this builder. pub fn build(self) -> Config { Config::new( self.host.unwrap_or_default(), self.auth, self.timeout.unwrap_or_else(|| Duration::from_secs(5)), + self.debug.unwrap_or(false), ) } } diff --git a/nut-client/src/var.rs b/nut-client/src/var.rs index c50a773..b39a8ca 100644 --- a/nut-client/src/var.rs +++ b/nut-client/src/var.rs @@ -79,28 +79,46 @@ impl Variable { _ => Self::Other((name.into(), value)), } } + + /// Returns the NUT name of the variable. + pub fn name(&self) -> &str { + use self::key::*; + match self { + Self::DeviceModel(_) => DEVICE_MODEL, + Self::DeviceManufacturer(_) => DEVICE_MANUFACTURER, + Self::DeviceSerial(_) => DEVICE_SERIAL, + Self::DeviceType(_) => DEVICE_TYPE, + Self::DeviceDescription(_) => DEVICE_DESCRIPTION, + Self::DeviceContact(_) => DEVICE_CONTACT, + Self::DeviceLocation(_) => DEVICE_LOCATION, + Self::DevicePart(_) => DEVICE_PART, + Self::DeviceMacAddress(_) => DEVICE_MAC_ADDRESS, + Self::DeviceUptime(_) => DEVICE_UPTIME, + Self::Other((name, _)) => name.as_str(), + } + } + + /// Returns the value of the NUT variable. + pub fn value(&self) -> String { + match self { + Self::DeviceModel(value) => value.clone(), + Self::DeviceManufacturer(value) => value.clone(), + Self::DeviceSerial(value) => value.clone(), + Self::DeviceType(value) => value.to_string(), + Self::DeviceDescription(value) => value.clone(), + Self::DeviceContact(value) => value.clone(), + Self::DeviceLocation(value) => value.clone(), + Self::DevicePart(value) => value.clone(), + Self::DeviceMacAddress(value) => value.clone(), + Self::DeviceUptime(value) => value.as_secs().to_string(), + Self::Other((_, value)) => value.clone(), + } + } } impl fmt::Display for Variable { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - use self::key::*; - - match self { - Self::DeviceModel(value) => write!(f, "{} = {}", DEVICE_MODEL, value), - Self::DeviceManufacturer(value) => write!(f, "{} = {}", DEVICE_MANUFACTURER, value), - Self::DeviceSerial(value) => write!(f, "{} = {}", DEVICE_SERIAL, value), - Self::DeviceType(value) => write!(f, "{} = {}", DEVICE_TYPE, value), - Self::DeviceDescription(value) => write!(f, "{} = {}", DEVICE_DESCRIPTION, value), - Self::DeviceContact(value) => write!(f, "{} = {}", DEVICE_CONTACT, value), - Self::DeviceLocation(value) => write!(f, "{} = {}", DEVICE_LOCATION, value), - Self::DevicePart(value) => write!(f, "{} = {}", DEVICE_PART, value), - Self::DeviceMacAddress(value) => write!(f, "{} = {}", DEVICE_MAC_ADDRESS, value), - Self::DeviceUptime(value) => { - write!(f, "{} = {} seconds", DEVICE_UPTIME, value.as_secs()) - } - - Self::Other((key, value)) => write!(f, "other({}) = {}", key, value), - } + write!(f, "{}: {}", self.name(), self.value()) } } diff --git a/rupsc/Cargo.toml b/rupsc/Cargo.toml new file mode 100644 index 0000000..6ef79ca --- /dev/null +++ b/rupsc/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "rupsc" +version = "0.0.4" +authors = ["Aram Peres "] +edition = "2018" +description = "A demo program to display UPS variables" +categories = ["network-programming"] +keywords = ["ups", "nut"] +repository = "https://github.com/aramperes/nut-client-rs" +readme = "README.md" +license = "MIT" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = "2.33.3" +anyhow = "1" + +[dependencies.nut-client] +version = "0.0.4" +path = "../nut-client" diff --git a/rupsc/LICENSE b/rupsc/LICENSE new file mode 100644 index 0000000..edeffdb --- /dev/null +++ b/rupsc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2021 Aram Peres + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rupsc/README.md b/rupsc/README.md new file mode 100644 index 0000000..145af59 --- /dev/null +++ b/rupsc/README.md @@ -0,0 +1,65 @@ +# rupsc + +[![crates.io](https://img.shields.io/crates/v/rupsc.svg)](https://crates.io/crates/rupsc) +[![Documentation](https://docs.rs/nut-client/badge.svg)](https://docs.rs/nut-client) +[![MIT licensed](https://img.shields.io/crates/l/rupsc.svg)](./LICENSE) +[![CI](https://github.com/aramperes/nut-client-rs/workflows/CI/badge.svg)](https://github.com/aramperes/nut-client-rs/actions?query=workflow%3ACI) + +A Rust clone of [upsc](https://networkupstools.org/docs/man/upsc.html), +the [Network UPS Tools](https://github.com/networkupstools/nut) (NUT) demo program to display UPS variables. + +Written using the [nut-client](https://github.com/aramperes/nut-client-rs) crate. + +- Connect to `upsd`/`nut-server` using TCP +- List UPS devices +- List variables for a UPS device +- Get variable value of a UPS device +- List clients connected to a UPS device + +## Installation + +```bash +# Using cargo +cargo install rupsc + +# Or, build for other targets +# (make sure you install the appropriate toolchain & gcc linker) +cargo build --release --target armv7-unknown-linux-gnueabihf +cargo build --release --target aarch64-unknown-linux-gnu +cargo build --release --target arm-unknown-linux-gnueabihf +``` + +## Usage + +This is a clone of [`upsc`](https://networkupstools.org/docs/man/upsc.html), so the usage is the same: + +```bash +# Show usage +rupsc -h + +# List variables on UPS device "nutdev1" (assumes upsd running on 127.0.0.1:3493) +rupsc nutdev1 + +# List variables on UPS device "nutdev1" (remove upsd) +rupsc nutdev1@192.168.1.2:3493 + +# List available UPS devices +rupsc -l + +# List available UPS devices, with description +rupsc -L + +# List clients connected to UPS device "nutdev1" +rupsc -c nutdev1 +``` + +However, there are also some additions: + +```bash +# Enable network debugging (global flag). +ruspc -D +``` + +## Pronunciation + +> r-oopsie diff --git a/rupsc/src/cmd.rs b/rupsc/src/cmd.rs new file mode 100644 index 0000000..1d44f5b --- /dev/null +++ b/rupsc/src/cmd.rs @@ -0,0 +1,66 @@ +use crate::parser::UpsdName; +use anyhow::Context; +use core::convert::TryInto; +use nut_client::blocking::Connection; + +/// Lists each UPS on the upsd server, one per line. +pub fn list_devices(server: UpsdName, with_description: bool, debug: bool) -> anyhow::Result<()> { + let mut conn = connect(server, debug)?; + + for (name, description) in conn.list_ups()? { + if with_description { + println!("{}: {}", name, description); + } else { + println!("{}", name); + } + } + + Ok(()) +} + +pub fn print_variable(server: UpsdName, variable: &str, debug: bool) -> anyhow::Result<()> { + let ups_name = server + .upsname + .with_context(|| "ups name must be specified: [@[:]]")?; + let mut conn = connect(server, debug)?; + + let variable = conn.get_var(ups_name, variable)?; + println!("{}", variable.value()); + + Ok(()) +} + +pub fn list_variables(server: UpsdName, debug: bool) -> anyhow::Result<()> { + let ups_name = server + .upsname + .with_context(|| "ups name must be specified: [@[:]]")?; + let mut conn = connect(server, debug)?; + + for var in conn.list_vars(ups_name)? { + println!("{}", var); + } + + Ok(()) +} + +pub fn list_clients(server: UpsdName, debug: bool) -> anyhow::Result<()> { + let ups_name = server + .upsname + .with_context(|| "ups name must be specified: [@[:]]")?; + let mut conn = connect(server, debug)?; + + for client_ip in conn.list_clients(ups_name)? { + println!("{}", client_ip); + } + + Ok(()) +} + +fn connect(server: UpsdName, debug: bool) -> anyhow::Result { + let host = server.try_into()?; + let config = nut_client::ConfigBuilder::new() + .with_host(host) + .with_debug(debug) + .build(); + Connection::new(config).with_context(|| format!("Failed to connect to upsd: {}", server)) +} diff --git a/rupsc/src/main.rs b/rupsc/src/main.rs new file mode 100644 index 0000000..d367f6e --- /dev/null +++ b/rupsc/src/main.rs @@ -0,0 +1,85 @@ +///! # rupsc +///! A demo program to display UPS variables. +///! This a Rust clone of [upsc](https://github.com/networkupstools/nut/blob/master/clients/upsc.c). +///! +///! P.S.: pronounced "r-oopsie". +mod cmd; +mod parser; + +use crate::parser::UpsdName; +use anyhow::Context; +use clap::{App, Arg}; +use core::convert::TryInto; + +fn main() -> anyhow::Result<()> { + let args = App::new(clap::crate_name!()) + .version(clap::crate_version!()) + .author(clap::crate_authors!()) + .about(clap::crate_description!()) + .arg( + Arg::with_name("list") + .short("l") + .conflicts_with_all(&["list-full", "clients"]) + .takes_value(false) + .help("Lists each UPS on , one per line."), + ) + .arg( + Arg::with_name("list-full") + .short("L") + .conflicts_with_all(&["list", "clients"]) + .takes_value(false) + .help("Lists each UPS followed by its description (from ups.conf)."), + ) + .arg( + Arg::with_name("clients") + .short("c") + .conflicts_with_all(&["list", "list-full"]) + .takes_value(false) + .help("Lists each client connected on , one per line."), + ) + .arg( + Arg::with_name("debug") + .short("D") + .takes_value(false) + .help("Enables debug mode (logs network commands to stderr)."), + ) + .arg( + Arg::with_name("upsd-server") + .required(false) + .value_name("[upsname][@[:]]") + .help("upsd server (with optional upsname, if applicable)."), + ) + .arg( + Arg::with_name("variable") + .required(false) + .value_name("variable") + .help("Optional, display this variable only."), + ) + .get_matches(); + + let server: parser::UpsdName = args.value_of("upsd-server").map_or_else( + || Ok(UpsdName::default()), + |s| s.try_into().with_context(|| "Invalid upsd server name"), + )?; + + let debug = args.is_present("debug"); + + if args.is_present("list") { + return cmd::list_devices(server, false, debug); + } + + if args.is_present("list-full") { + return cmd::list_devices(server, true, debug); + } + + if args.is_present("clients") { + return cmd::list_clients(server, debug); + } + + // Fallback: prints one variable (or all of them) + if let Some(variable) = args.value_of("variable") { + cmd::print_variable(server, variable, debug) + } else { + cmd::list_variables(server, debug) + } +} diff --git a/rupsc/src/parser.rs b/rupsc/src/parser.rs new file mode 100644 index 0000000..bf5779d --- /dev/null +++ b/rupsc/src/parser.rs @@ -0,0 +1,150 @@ +use anyhow::Context; +use std::convert::{TryFrom, TryInto}; +use std::fmt; +use std::net::ToSocketAddrs; + +pub const DEFAULT_HOSTNAME: &str = "127.0.0.1"; +pub const DEFAULT_PORT: u16 = 3493; + +/// Connection information for a upsd server. +/// +/// The upsname is optional depending on context. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct UpsdName<'a> { + pub upsname: Option<&'a str>, + pub hostname: &'a str, + pub port: u16, +} + +impl<'a> Default for UpsdName<'a> { + fn default() -> Self { + UpsdName { + upsname: None, + hostname: DEFAULT_HOSTNAME, + port: DEFAULT_PORT, + } + } +} + +impl<'a> TryFrom<&'a str> for UpsdName<'a> { + type Error = anyhow::Error; + + fn try_from(value: &'a str) -> anyhow::Result> { + let mut upsname: Option<&str> = None; + let mut hostname = DEFAULT_HOSTNAME; + let mut port = DEFAULT_PORT; + + if value.contains(':') { + let mut split = value.splitn(2, ':'); + let prefix = split.next().unwrap(); + port = split + .next() + .unwrap() + .parse::() + .with_context(|| "invalid port number")?; + if prefix.contains('@') { + let mut split = prefix.splitn(2, '@'); + upsname = Some(split.next().unwrap()); + hostname = split.next().unwrap(); + } else { + hostname = prefix; + } + } else if value.contains('@') { + let mut split = value.splitn(2, '@'); + upsname = Some(split.next().unwrap()); + hostname = split.next().unwrap(); + } else { + upsname = Some(value); + } + + Ok(UpsdName { + upsname, + hostname, + port, + }) + } +} + +impl<'a> TryInto for UpsdName<'a> { + type Error = anyhow::Error; + + fn try_into(self) -> anyhow::Result { + Ok((String::from(self.hostname), self.port) + .to_socket_addrs() + .with_context(|| "Failed to convert to SocketAddr")? + .next() + .with_context(|| "Failed to convert to SocketAddr")? + .into()) + } +} + +impl<'a> fmt::Display for UpsdName<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(upsname) = self.upsname { + write!(f, "{}@", upsname)?; + } + write!(f, "{}:{}", self.hostname, self.port) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::convert::TryInto; + + #[test] + fn test_upsdname_parser_full() { + let name: UpsdName = "ups@notlocal:1234".try_into().unwrap(); + assert_eq!( + name, + UpsdName { + upsname: Some("ups"), + hostname: "notlocal", + port: 1234 + } + ); + assert_eq!(format!("{}", name), "ups@notlocal:1234"); + } + + #[test] + fn test_upsdname_parser_no_name() { + let name: UpsdName = "notlocal:5678".try_into().unwrap(); + assert_eq!( + name, + UpsdName { + upsname: None, + hostname: "notlocal", + port: 5678 + } + ); + assert_eq!(format!("{}", name), "notlocal:5678"); + } + + #[test] + fn test_upsdname_parser_no_port_no_hostname() { + let name: UpsdName = "ups0".try_into().unwrap(); + assert_eq!( + name, + UpsdName { + upsname: Some("ups0"), + hostname: DEFAULT_HOSTNAME, + port: DEFAULT_PORT + } + ); + assert_eq!(format!("{}", name), "ups0@127.0.0.1:3493"); + } + + #[test] + fn test_upsdname_parser_no_port() { + let name: UpsdName = "ups@notlocal".try_into().unwrap(); + assert_eq!( + name, + UpsdName { + upsname: Some("ups"), + hostname: "notlocal", + port: DEFAULT_PORT + } + ); + assert_eq!(format!("{}", name), "ups@notlocal:3493"); + } +}