Skip to main content

i3status_rs/blocks/
speedtest.rs

1//! Ping, jitter, download, and upload speeds
2//!
3//! This block uses Cloudflare's [networkquality-rs](https://github.com/cloudflare/networkquality-rs) (nq) library to run a speedtest and report the ping, jitter, download speed, and upload speed.
4//!
5//! The block can be configured to use custom endpoints for the speedtest, but by default Cloudflare's nq endpoints are used.
6//!
7//!  For example setting `config_url` to `"https://mensura.cdn-apple.com/.well-known/nq"` will use Apple's nq endpoints instead of Cloudflare's.
8//!
9//! nq is based on the IETF draft: ["Responsiveness under Working Conditions"](https://datatracker.ietf.org/doc/draft-ietf-ippm-responsiveness/).
10//!
11//! The draft defines "responsiveness", measured in **R**ound trips **P**er **M**inute (RPM), as a useful measurement of network quality.
12//!
13//! # Configuration
14//!
15//! Key | Values | Default
16//! ----|--------|--------
17//! `format` | A string to customise the output of this block. See below for available placeholders. | `" ^icon_ping $ping.eng(prefix:m) ^icon_net_down $speed_down ^icon_net_up $speed_up "`
18//! `interval` | Update interval in seconds | `1800`
19//! `config_url` | The endpoint to get the responsiveness config from. See [`SpeedtestConfig::config_url`] for the expected format of the configuration JSON returned by this endpoint. | `None`
20//! `large_download_url` | The large file endpoint which should be multiple GBs. | `"https://h3.speed.cloudflare.com/__down?bytes=10000000000"`
21//! `small_download_url` | The small file endpoint which should be very small, only a few bytes. | `"https://h3.speed.cloudflare.com/__down?bytes=10"`
22//! `upload_url` | The upload url which accepts an arbitrary amount of data. | `"https://h3.speed.cloudflare.com/__up"`
23//! `latency` | Arguments for the latency test. | [See table below](#latency-configuration-settings-used-for-ping-and-jitter)
24//! `rpm` | Arguments for the RPM test. | [See table below](#rpm-configuration-settings-used-for-speed_down-and-speed_up)
25//!
26//! # Latency Configuration (settings used for `ping` and `jitter`)
27//!
28//! Key | Values | Default
29//! ----|--------|--------
30//! `runs` | The number of latency test runs to perform. | `20`
31//!
32//! # RPM Configuration (settings used for `speed_down` and `speed_up`)
33//!
34//! Key | Values | Default
35//! ----|--------|--------
36//! `moving_average_distance` | The number of intervals to use when calculating the moving average. | `4`
37//! `std_tolerance` | How far a measurement is allowed to be from the previous moving average before the measurement is considered unstable. | `0.05`
38//! `trimmed_mean_percent` | Determines which percentile to use for averaging when calculating the trimmed mean of throughputs or RPM scores. A value of `0.95` means to only use values in the 95th percentile to calculate an average. | `0.95`
39//! `max_loaded_connections` | The maximum number of loaded connections that the test can use to saturate the network. | `16`
40//! `interval_duration_ms` | The duration between test intervals in milliseconds (ms). | `500` (0.5 seconds)
41//! `test_duration_ms` | The overall test duration in milliseconds (ms). | `12_000` (12 seconds)
42//! `conn_type` | The type of connection to use for the speed test. One of `"h1"`, `"h2"`, or `"h3"` | `"h2"`
43//! `upload_bytes_per_request` | The number of bytes to upload per request during the speed test. | `100_000_000`
44//!
45//! # Available Format Keys
46//!
47//! Placeholder  | Value          | Type   | Unit
48//! -------------|----------------|--------|---------------
49//! `ping`       | Ping delay     | Number | Seconds
50//! `jitter`     | Jitter         | Number | Seconds
51//! `speed_down` | Download speed | Number | Bits per second
52//! `speed_up`   | Upload speed   | Number | Bits per second
53//!
54//! # Examples
55//!
56//! Show only ping (with an icon)
57//!
58//! ```toml
59//! [[block]]
60//! block = "speedtest"
61//! format = " ^icon_ping $ping "
62//! ```
63//!
64//! Hide ping and display speed in bytes per second each using 4 characters (without icons)
65//!
66//! ```toml
67//! [[block]]
68//! block = "speedtest"
69//! format = " $speed_down.eng(w:4,u:B) $speed_up(w:4,u:B) "
70//! ```
71//!
72//! Advanced configuration
73//!
74//! ```toml
75//! [[block]]
76//! block = "speedtest"
77//! [block.latency]
78//! runs = 5
79//! [block.rpm]
80//! conn_type = "h1"
81//! ```
82//!
83//! # Icons Used
84//! - `ping` (`^icon_ping`)
85//! - `net_down` (`^icon_net_down`)
86//! - `net_up` (`^icon_net_up`)
87
88use std::sync::Arc;
89
90use cf_mach::{
91    nq_core::{ConnectionType, Network, Time, TokioTime},
92    nq_latency::{Latency, LatencyConfig, LatencyResult},
93    nq_rpm::{ConnectionErrorPolicy, Responsiveness, ResponsivenessConfig, ResponsivenessResult},
94    nq_tokio_network::TokioNetwork,
95};
96use reqwest::Url;
97use serde::{Deserialize, Deserializer};
98use tokio_util::sync::CancellationToken;
99
100use super::prelude::*;
101
102make_log_macro!(debug, "speedtest");
103
104#[derive(Deserialize, Debug, SmartDefault)]
105#[serde(deny_unknown_fields, default)]
106pub struct Config {
107    pub format: FormatConfig,
108    #[default(1800.into())]
109    pub interval: Seconds,
110    #[serde(flatten)]
111    pub speedtest: SpeedtestConfig,
112}
113
114#[derive(Debug, Deserialize, SmartDefault)]
115#[serde(deny_unknown_fields, default)]
116pub struct SpeedtestConfig {
117    /// The endpoint to get the responsiveness config from. Should be JSON in
118    /// the form:
119    ///
120    /// ```json
121    /// {
122    ///     "version": number,
123    ///     "test_endpoint": string?,
124    ///     "urls": {
125    ///         "small_https_download_url": string,
126    ///         "large_https_download_url": string,
127    ///         "https_upload_url": string
128    ///     }
129    /// }
130    /// ```
131    #[serde(deserialize_with = "deserialize_url_opt")]
132    pub config_url: Option<Url>,
133    /// The large file endpoint which should be multiple GBs.
134    #[default("https://h3.speed.cloudflare.com/__down?bytes=10000000000".parse().unwrap())]
135    pub large_download_url: Url,
136    /// The small file endpoint which should be very small, only a few bytes.
137    #[default("https://h3.speed.cloudflare.com/__down?bytes=10".parse().unwrap())]
138    #[serde(deserialize_with = "deserialize_url")]
139    pub small_download_url: Url,
140    /// The upload url which accepts an arbitrary amount of data.
141    #[default("https://h3.speed.cloudflare.com/__up".parse().unwrap())]
142    #[serde(deserialize_with = "deserialize_url")]
143    pub upload_url: Url,
144    pub latency: LatencyConfigOpts,
145    pub rpm: RpmConfigOpts,
146}
147
148#[derive(Debug, Deserialize, SmartDefault)]
149#[serde(deny_unknown_fields, default)]
150pub struct LatencyConfigOpts {
151    /// The number of runs to perform when measuring latency.
152    #[default(20)]
153    pub runs: usize,
154}
155
156#[derive(Debug, Deserialize, SmartDefault)]
157#[serde(deny_unknown_fields, default)]
158pub struct RpmConfigOpts {
159    /// The number of intervals to use when calculating the moving average.
160    #[default(4)]
161    pub moving_average_distance: usize,
162    /// How far a measurement is allowed to be from the previous moving average
163    /// before the measurement is considered unstable.
164    #[default(0.05)]
165    pub std_tolerance: f64,
166    /// Determines which percentile to use for averaging when calculating the
167    /// trimmed mean of throughputs or RPM scores. A value of `0.95` means to
168    /// only use values in the 95th percentile to calculate an average.
169    #[default(0.95)]
170    pub trimmed_mean_percent: f64,
171    /// The maximum number of loaded connections that the test can use to
172    /// saturate the network.
173    #[default(16)]
174    pub max_loaded_connections: usize,
175    /// The duration between test intervals.
176    #[default(Duration::from_millis(500))]
177    #[serde(deserialize_with = "deserialize_duration_ms")]
178    pub interval_duration_ms: Duration,
179    /// The overall test duration.
180    #[default(Duration::from_millis(12_000))]
181    #[serde(deserialize_with = "deserialize_duration_ms")]
182    pub test_duration_ms: Duration,
183    /// Create an HTTP/1.1, HTTP/2, or HTTP/3 connection.
184    #[default(ConnectionType::H2)]
185    #[serde(deserialize_with = "deserialize_conn_type")]
186    pub conn_type: ConnectionType,
187    /// Maximum bytes sent in any single upload load-generating request.
188    ///
189    /// Upload load is generated as a sequence of requests of this size on each
190    /// connection, rather than one enormous request, because servers may cap
191    /// request body size and reject anything larger with HTTP 413. Such caps
192    /// apply per-request, so staying under one here keeps the link loaded
193    /// indefinitely without ever tripping it.
194    ///
195    /// Must be below the smallest such cap on the path, with margin. It has no
196    /// effect on connections too slow to send this many bytes within the test
197    /// duration, since their first request never completes either way.
198    #[default(100_000_000)]
199    pub upload_bytes_per_request: usize,
200}
201
202pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
203    // The icons (`ping`, `net_down`, `net_up`) are rendered by `^icon_*`
204    // format tokens, not icon-valued placeholders, so no icons are declared.
205    BlockPlan::new(vec![OutputPlan::new(
206        "main",
207        config.format.with_default(
208            " ^icon_ping $ping.eng(prefix:m) ^icon_net_down $speed_down ^icon_net_up $speed_up ",
209        )?,
210    )])
211}
212
213pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
214    let output_main = plan.output("main")?;
215    let format = output_main.format();
216
217    let need_ping = format.contains_key("ping");
218    let need_jitter = format.contains_key("jitter");
219    let need_speed_down = format.contains_key("speed_down");
220    let need_speed_up = format.contains_key("speed_up");
221
222    loop {
223        let speedtest_urls = get_speedtest_urls(&config.speedtest).await?;
224
225        let mut values = HashMap::new();
226
227        if need_ping || need_jitter {
228            debug!("running latency test");
229
230            let latency_results = test_latency(LatencyConfig {
231                url: speedtest_urls.small_https_download_url.clone(),
232                runs: config.speedtest.latency.runs,
233                scoped_headers: None,
234            })
235            .await?;
236
237            if need_ping {
238                values.insert(
239                    "ping".into(),
240                    Value::seconds(latency_results.median().error("no median RTT available")?),
241                );
242            }
243
244            if need_jitter {
245                values.insert(
246                    "jitter".into(),
247                    Value::seconds(latency_results.jitter().error("no jitter available")?),
248                );
249            }
250        }
251
252        if need_speed_down || need_speed_up {
253            let responsiveness_config = ResponsivenessConfig {
254                large_download_url: speedtest_urls.large_https_download_url,
255                small_download_url: speedtest_urls.small_https_download_url,
256                upload_url: speedtest_urls.https_upload_url,
257                moving_average_distance: config.speedtest.rpm.moving_average_distance,
258                interval_duration: config.speedtest.rpm.interval_duration_ms,
259                test_duration: config.speedtest.rpm.test_duration_ms,
260                trimmed_mean_percent: config.speedtest.rpm.trimmed_mean_percent,
261                std_tolerance: config.speedtest.rpm.std_tolerance,
262                max_loaded_connections: config.speedtest.rpm.max_loaded_connections,
263                conn_type: config.speedtest.rpm.conn_type,
264                upload_bytes_per_request: config.speedtest.rpm.upload_bytes_per_request,
265                // This is false for RPM, but true for a saturation test
266                determine_load_only: false,
267                on_connection_error: ConnectionErrorPolicy::default(),
268                scoped_headers: None,
269            };
270
271            if need_speed_down {
272                debug!("running download test");
273                let download_result = test_network_speed(&responsiveness_config, true).await?;
274                values.insert(
275                    "speed_down".into(),
276                    Value::bits(
277                        download_result
278                            .throughput()
279                            .error("no download throughput available")?,
280                    ),
281                );
282            }
283
284            if need_speed_up {
285                debug!("running upload test");
286                let upload_result = test_network_speed(&responsiveness_config, false).await?;
287                values.insert(
288                    "speed_up".into(),
289                    Value::bits(
290                        upload_result
291                            .throughput()
292                            .error("no upload throughput available")?,
293                    ),
294                );
295            }
296        }
297
298        let mut widget = output_main.new_widget();
299        widget.set_values(values);
300        api.set_widget(widget)?;
301
302        select! {
303            _ = sleep(config.interval.0) => (),
304            _ = api.wait_for_update_request() => (),
305        }
306    }
307}
308
309#[derive(Debug, Deserialize)]
310struct SpeedtestUrls {
311    #[serde(alias = "small_download_url", deserialize_with = "deserialize_url")]
312    small_https_download_url: Url,
313    #[serde(alias = "large_download_url", deserialize_with = "deserialize_url")]
314    large_https_download_url: Url,
315    #[serde(alias = "upload_url", deserialize_with = "deserialize_url")]
316    https_upload_url: Url,
317}
318
319#[derive(Deserialize)]
320struct RpmServerConfig {
321    urls: SpeedtestUrls,
322}
323
324/// Get speedtest urls
325async fn get_speedtest_urls(speedtest_config: &SpeedtestConfig) -> Result<SpeedtestUrls> {
326    match speedtest_config.config_url.clone() {
327        Some(config_url) => {
328            debug!("fetching configuration from {config_url}");
329            let urls = REQWEST_CLIENT
330                .get(config_url)
331                .send()
332                .await
333                .error("Failed to send request with reqwest")?
334                .json::<RpmServerConfig>()
335                .await
336                .error("Failed to parse JSON from rpm config endpoint")?
337                .urls;
338            debug!("retrieved configuration urls: {urls:?}");
339
340            Ok(urls)
341        }
342        None => Ok(SpeedtestUrls {
343            small_https_download_url: speedtest_config.small_download_url.clone(),
344            large_https_download_url: speedtest_config.large_download_url.clone(),
345            https_upload_url: speedtest_config.upload_url.clone(),
346        }),
347    }
348}
349
350async fn test_latency(config: LatencyConfig) -> Result<LatencyResult> {
351    let shutdown = CancellationToken::new();
352    let time: Arc<dyn Time> = Arc::new(TokioTime::new());
353    let network: Arc<dyn Network> =
354        Arc::new(TokioNetwork::new(Arc::clone(&time), shutdown.clone()));
355
356    let rtt = Latency::new(config);
357    let result = rtt
358        .run_test(network, time, shutdown.clone())
359        .await
360        .map_err(|e| Error::new(e.to_string()))?;
361
362    debug!("shutting down latency test");
363    let _ = tokio::time::timeout(tokio::time::Duration::from_secs(1), async {
364        shutdown.cancel();
365    })
366    .await;
367
368    Ok(result)
369}
370
371async fn test_network_speed(
372    config: &ResponsivenessConfig,
373    download: bool,
374) -> Result<ResponsivenessResult> {
375    let shutdown = CancellationToken::new();
376    let time: Arc<dyn Time> = Arc::new(TokioTime::new());
377    let network: Arc<dyn Network> =
378        Arc::new(TokioNetwork::new(Arc::clone(&time), shutdown.clone()));
379
380    let rpm =
381        Responsiveness::new(config.clone(), download).map_err(|e| Error::new(e.to_string()))?;
382    let result = rpm
383        .run_test(network, time, shutdown.clone())
384        .await
385        .map_err(|e| Error::new(e.to_string()))?;
386
387    debug!("shutting down network speed test");
388    let _ = tokio::time::timeout(tokio::time::Duration::from_secs(1), async {
389        shutdown.cancel();
390    })
391    .await;
392
393    Ok(result)
394}
395
396fn deserialize_url_opt<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
397where
398    D: Deserializer<'de>,
399{
400    let url_opt = Option::<String>::deserialize(deserializer)?;
401    url_opt
402        .map(|url| url.parse().map_err(serde::de::Error::custom))
403        .transpose()
404}
405
406fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
407where
408    D: Deserializer<'de>,
409{
410    let url = String::deserialize(deserializer)?;
411    url.parse().map_err(serde::de::Error::custom)
412}
413
414fn deserialize_duration_ms<'de, D>(deserializer: D) -> Result<Duration, D::Error>
415where
416    D: Deserializer<'de>,
417{
418    let duration_ms = u64::deserialize(deserializer)?;
419    Ok(Duration::from_millis(duration_ms))
420}
421
422fn deserialize_conn_type<'de, D>(deserializer: D) -> Result<ConnectionType, D::Error>
423where
424    D: Deserializer<'de>,
425{
426    let conn_type_str = String::deserialize(deserializer)?;
427    match conn_type_str.as_str() {
428        "h1" => Ok(ConnectionType::H1 { use_tls: true }),
429        "h2" => Ok(ConnectionType::H2),
430        "h3" => Ok(ConnectionType::H3),
431        _ => Err(serde::de::Error::custom(format!(
432            "Invalid connection type: {}. Must be one of: h1, h2, h3",
433            conn_type_str
434        ))),
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn plan_declares_main_output_without_icon_placeholders() {
444        let plan = prepare(&Config::default()).unwrap();
445        let declared: Vec<_> = plan.outputs().map(|o| o.id()).collect();
446        assert_eq!(declared, ["main"]);
447        let output = plan.output("main").unwrap();
448        // Every icon this block draws is a `^icon_*` token in the format
449        // rather than an icon value, so the icon surface is entirely static.
450        assert_eq!(output.output().icon_placeholders().count(), 0);
451        assert_eq!(
452            output.output().static_icons(),
453            ["ping", "net_down", "net_up"]
454        );
455    }
456
457    #[test]
458    fn a_format_without_icons_declares_none() {
459        let config = Config {
460            format: " $ping ".parse().unwrap(),
461            ..Config::default()
462        };
463        let plan = prepare(&config).unwrap();
464        let output = plan.output("main").unwrap();
465        assert!(output.output().static_icons().is_empty());
466    }
467
468    #[test]
469    fn custom_format_is_respected() {
470        let config = Config {
471            format: " $ping ".parse().unwrap(),
472            ..Config::default()
473        };
474        let plan = prepare(&config).unwrap();
475        let output = plan.output("main").unwrap();
476        assert!(output.format().contains_key("ping"));
477        assert!(!output.format().contains_key("speed_down"));
478    }
479}