Compare commits

..

10 Commits
v1.0.0 ... main

Author SHA1 Message Date
ad0a24201c
[release] linkspeed v0.1.3 2025-03-29 20:35:03 +01:00
f3f1cb46c4
[deps] cargo update
Updating once_cell v1.21.1 -> v1.21.3
2025-03-29 20:35:02 +01:00
c9180033d1
[release] linkspeed v0.1.2 2025-03-14 01:09:28 +01:00
a03a679f71
[code] better error handling and show available netdevs on some errors 2025-03-14 01:09:03 +01:00
3963bce64e
[release] linkspeed v0.1.1 2025-03-13 23:23:51 +01:00
eb95d9593a
[feat] config file. also: extreme over-engineering 2025-03-13 23:23:36 +01:00
f23a68e544
[rustfmt] add rustfmt.toml 2025-03-13 23:09:26 +01:00
8f4542159f
[build] profile release and release-lto 2025-03-13 23:09:09 +01:00
40bcfe4a13
[clippy] add lints 2025-03-13 23:08:32 +01:00
816e3110bd
[deps] add eyre 2025-03-13 23:08:16 +01:00
9 changed files with 401 additions and 83 deletions

27
Cargo.lock generated
View File

@ -2,6 +2,31 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]]
name = "linkspeed"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"eyre",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

View File

@ -1,13 +1,35 @@
[package]
name = "linkspeed"
version = "0.1.0"
version = "0.1.3"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
redundant_closure_for_method_calls = "allow"
similar_names = "allow"
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[dependencies]
eyre = "0.6"
[profile.release]
lto = true
debug = false
strip = true
debug = true
debug-assertions = true
overflow-checks = true
strip = false
lto = false
panic = "unwind"
codegen-units = 1
[profile.release-lto]
inherits = "release"
lto = true
debug = false
debug-assertions = false
overflow-checks = false
strip = true

3
rustfmt.toml Normal file
View File

@ -0,0 +1,3 @@
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"

64
src/cfg.rs Normal file
View File

@ -0,0 +1,64 @@
use std::{
env,
fs::File,
io::{BufRead as _, BufReader},
path::PathBuf,
time::Duration,
};
use eyre::ContextCompat as _;
pub(crate) struct Config {
pub(crate) netdev: Option<String>,
pub(crate) interval: Duration,
}
impl Config {
const CONFIG_FILE: &'static str = ".config/pfs/linkspeed";
pub(crate) fn from_file() -> eyre::Result<Self> {
let config_file = PathBuf::from(env::var("HOME")?).join(Self::CONFIG_FILE);
let mut config = Self::default();
if let Ok(file) = File::open(&config_file) {
let file = BufReader::new(file);
for line in file.lines() {
let line = line?;
match &line {
line if line.starts_with("netdev") => {
config.netdev = Some(Self::read_config_value(line)?.to_string());
}
line if line.starts_with("interval") => {
let interval = Self::read_config_value(line)?.parse::<f64>()?;
config.interval = Duration::from_secs_f64(interval);
}
_ => {}
}
}
}
Ok(config)
}
fn read_config_value(line: &str) -> eyre::Result<&str> {
let val = line
.split('=')
.nth(1)
.wrap_err("failed to parse value".to_string())?
.trim();
Ok(val)
}
}
impl Default for Config {
fn default() -> Self {
Config {
netdev: None,
interval: Duration::from_secs(1),
}
}
}

57
src/macros.rs Normal file
View File

@ -0,0 +1,57 @@
mod byte_conversion {
#[macro_export]
macro_rules! kbit {
($bytes:expr) => {
kbyte!($bytes) * 8.0
};
}
#[macro_export]
macro_rules! mbit {
($bytes:expr) => {
mbyte!($bytes) * 8.0
};
}
#[macro_export]
macro_rules! gbit {
($bytes:expr) => {
gbyte!($bytes) * 8.0
};
}
#[macro_export]
macro_rules! tbit {
($bytes:expr) => {
tbyte!($bytes) * 8.0
};
}
#[macro_export]
macro_rules! kbyte {
($bytes:expr) => {
$bytes / 1024.0
};
}
#[macro_export]
macro_rules! mbyte {
($bytes:expr) => {
kbyte!($bytes) / 1024.0
};
}
#[macro_export]
macro_rules! gbyte {
($bytes:expr) => {
mbyte!($bytes) / 1024.0
};
}
#[macro_export]
macro_rules! tbyte {
($bytes:expr) => {
gbyte!($bytes) / 1024.0
};
}
}

View File

@ -1,92 +1,45 @@
use std::{
env,
fs::File,
io::{self, Read, Seek, SeekFrom},
thread::sleep,
time::{Duration, Instant},
};
mod cfg;
mod macros;
mod math;
mod measure;
mod sysfs;
struct LinkSpeed {
rx: File,
tx: File,
rx_bytes: u64,
tx_bytes: u64,
time: Instant,
}
use std::{env, thread::sleep};
impl LinkSpeed {
pub fn new(dev: String) -> io::Result<Self> {
let rx_bytes_file = File::open(format!("/sys/class/net/{dev}/statistics/rx_bytes"))?;
let tx_bytes_file = File::open(format!("/sys/class/net/{dev}/statistics/tx_bytes"))?;
use sysfs::NetdevError;
Ok(Self {
rx: rx_bytes_file,
tx: tx_bytes_file,
rx_bytes: 0,
tx_bytes: 0,
time: Instant::now(),
})
}
use crate::{cfg::Config, measure::LinkSpeed};
fn read_bytes(file: &mut File) -> io::Result<u64> {
file.seek(SeekFrom::Start(0))?;
let mut buf = String::new();
file.read_to_string(&mut buf)?;
Ok(buf.trim().parse().unwrap_or(0))
}
fn main() -> eyre::Result<()> {
let config = Config::from_file()?;
fn update(&mut self) -> io::Result<(u64, u64)> {
let rx_bytes = Self::read_bytes(&mut self.rx)?;
let tx_bytes = Self::read_bytes(&mut self.tx)?;
let arg_netdev = env::args().nth(1);
let mut rx_diff = rx_bytes - self.rx_bytes;
let mut tx_diff = tx_bytes - self.tx_bytes;
if self.rx_bytes == 0 {
self.rx_bytes = rx_bytes;
rx_diff = 0;
// prefer given argument over the config file
#[allow(clippy::match_same_arms)]
let netdev_name = match (config.netdev, arg_netdev) {
(Some(_), Some(na)) => na,
(Some(nc), None) => nc,
(None, Some(na)) => na,
(None, None) => {
eyre::bail!(
"No network device specified. Please provide a network device name as an argument or in the config file.\n\n{}",
NetdevError::available_netdevs_msg()
);
}
};
if self.tx_bytes == 0 {
self.tx_bytes = tx_bytes;
tx_diff = 0;
}
let link_speed = LinkSpeed::new(&netdev_name)?;
self.rx_bytes = rx_bytes;
self.tx_bytes = tx_bytes;
Ok((rx_diff, tx_diff))
}
pub fn get_measurement(&mut self) -> (f64, f64) {
let elapsed = self.time.elapsed().as_secs_f64();
let (rx, tx) = self.update().unwrap();
let rx_speed = rx as f64 / elapsed;
let tx_speed = tx as f64 / elapsed;
self.time = Instant::now();
(rx_speed, tx_speed)
}
}
impl Iterator for LinkSpeed {
type Item = (f64, f64);
fn next(&mut self) -> Option<Self::Item> {
Some(self.get_measurement())
}
}
fn main() {
let netdev_name = env::args().nth(1).expect("No network device provided");
let link_speed = LinkSpeed::new(netdev_name).expect("Failed to create LinkSpeed object");
link_speed.for_each(|(rx_speed, tx_speed)| {
link_speed.for_each(|measurement| {
println!(
"RX: {:.0} MBit/s, TX: {:.0} MBit/s",
rx_speed / 1024.0 / 1024.0 * 8.0,
tx_speed / 1024.0 / 1024.0 * 8.0
mbit!(measurement.rx),
mbit!(measurement.tx)
);
sleep(Duration::from_millis(1000));
sleep(config.interval);
});
Ok(())
}

10
src/math.rs Normal file
View File

@ -0,0 +1,10 @@
pub(crate) fn safe_u64_to_f64(value: u64) -> eyre::Result<f64> {
const MAX_SAFE_INT: u64 = 1 << 53;
if value < MAX_SAFE_INT {
#[allow(clippy::cast_precision_loss)]
Ok(value as f64)
} else {
eyre::bail!("Value {value} exceeds maximum safe integer, potential precision loss")
}
}

82
src/measure.rs Normal file
View File

@ -0,0 +1,82 @@
use std::time::Instant;
use crate::{math::safe_u64_to_f64, sysfs::Netdev};
pub(crate) struct LinkSpeed {
netdev: Netdev,
rx_bytes: u64,
tx_bytes: u64,
time: Instant,
}
pub(crate) struct MeasurementSpeed {
pub(crate) rx: f64,
pub(crate) tx: f64,
}
struct MeasurementDiff {
rx: u64,
tx: u64,
}
impl LinkSpeed {
pub fn new(name: &str) -> eyre::Result<Self> {
let netdev = Netdev::from_name(name)?;
Ok(Self {
netdev,
rx_bytes: 0,
tx_bytes: 0,
time: Instant::now(),
})
}
fn update(&mut self) -> eyre::Result<MeasurementDiff> {
let rx_bytes = self.netdev.rx_bytes()?;
let tx_bytes = self.netdev.tx_bytes()?;
let mut rx_diff = rx_bytes - self.rx_bytes;
let mut tx_diff = tx_bytes - self.tx_bytes;
if self.rx_bytes == 0 {
self.rx_bytes = rx_bytes;
rx_diff = 0;
}
if self.tx_bytes == 0 {
self.tx_bytes = tx_bytes;
tx_diff = 0;
}
self.rx_bytes = rx_bytes;
self.tx_bytes = tx_bytes;
Ok(MeasurementDiff {
rx: rx_diff,
tx: tx_diff,
})
}
pub fn get_measurement(&mut self) -> eyre::Result<MeasurementSpeed> {
let elapsed = self.time.elapsed().as_secs_f64();
let diff = self.update()?;
let rx_speed = safe_u64_to_f64(diff.rx)? / elapsed;
let tx_speed = safe_u64_to_f64(diff.tx)? / elapsed;
self.time = Instant::now();
Ok(MeasurementSpeed {
rx: rx_speed,
tx: tx_speed,
})
}
}
impl Iterator for LinkSpeed {
type Item = MeasurementSpeed;
fn next(&mut self) -> Option<Self::Item> {
let measurement = self.get_measurement();
match measurement {
Ok(measurement) => Some(measurement),
Err(e) => panic!("Error getting measurement: {e}"),
}
}
}

102
src/sysfs.rs Normal file
View File

@ -0,0 +1,102 @@
use std::{
fs::File,
io::{Read as _, Seek as _, SeekFrom},
};
pub struct Netdev {
rx_bytes: File,
tx_bytes: File,
}
#[derive(Debug)]
pub enum NetdevError {
NotFound { name: String },
IoError { name: String, err: std::io::Error },
}
impl NetdevError {
pub fn available_netdevs_msg() -> String {
let list = Netdev::list()
.unwrap_or_default()
.into_iter()
.map(|dev| format!(" {dev}"))
.collect::<Vec<_>>()
.join("\n");
format!("Available network devices:\n{list}")
}
}
impl std::fmt::Display for NetdevError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NetdevError::NotFound { name } => write!(
f,
"Netdev '{name}' not found.\n\n{}",
Self::available_netdevs_msg()
),
NetdevError::IoError { name, err } => {
write!(f, "I/O Error occurred for netdev '{name}': {err}")
}
}
}
}
impl Netdev {
const SYS_CLASS_NET: &'static str = "/sys/class/net";
pub fn from_name(name: &str) -> eyre::Result<Self> {
let rx_bytes = Self::open_netdev_stat(name, "rx_bytes")?;
let tx_bytes = Self::open_netdev_stat(name, "tx_bytes")?;
Ok(Self { rx_bytes, tx_bytes })
}
fn open_netdev_stat(name: &str, stat: &str) -> eyre::Result<File> {
let sys_class_net = Self::SYS_CLASS_NET;
let path = format!("{sys_class_net}/{name}/statistics/{stat}");
match File::open(&path) {
Ok(file) => Ok(file),
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
Err(eyre::eyre!(NetdevError::NotFound {
name: name.to_string(),
}))
} else {
Err(eyre::eyre!(NetdevError::IoError {
name: name.to_string(),
err: e,
}))
}
}
}
}
fn read_bytes(file: &mut File) -> eyre::Result<u64> {
file.seek(SeekFrom::Start(0))?;
let mut buf = String::new();
file.read_to_string(&mut buf)?;
Ok(buf.trim().parse().unwrap_or(0))
}
pub fn rx_bytes(&mut self) -> eyre::Result<u64> {
Self::read_bytes(&mut self.rx_bytes)
}
pub fn tx_bytes(&mut self) -> eyre::Result<u64> {
Self::read_bytes(&mut self.tx_bytes)
}
pub fn list() -> eyre::Result<Vec<String>> {
let mut entries = std::fs::read_dir(Self::SYS_CLASS_NET)?
.filter_map(|entry| {
entry
.ok()
.and_then(|entry| entry.file_name().into_string().ok())
})
.collect::<Vec<_>>();
entries.sort();
Ok(entries)
}
}