Skip to main content

i3status_rs/blocks/
temperature.rs

1//! The system temperature
2//!
3//! This block displays the system temperature, based on `libsensors` library.
4//!
5//! This block has two modes: "collapsed", which uses only color as an indicator, and "expanded",
6//! which shows the content of a `format` string. The average, minimum, and maximum temperatures
7//! are computed using all sensors displayed by `sensors`, or optionally filtered by `chip` and
8//! `inputs`.
9//!
10//! Requires `libsensors` and appropriate kernel modules for your hardware.
11//!
12//! Run `sensors` command to list available chips and inputs.
13//!
14//! Note that the colour of the block is always determined by the maximum temperature across all
15//! sensors, not the average. You may need to keep this in mind if you have a misbehaving sensor.
16//!
17//! # Configuration
18//!
19//! Key | Values | Default
20//! ----|--------|--------
21//! `format` | A [MultiFormat][MaybeMultiFormatConfig] string to customise the output of this block. See below for available placeholders | `[" $icon $average avg, $max max "]`
22//! `interval` | Update interval in seconds | `5`
23//! `scale` | Either `"celsius"` or `"fahrenheit"` | `"celsius"`
24//! `good` | Maximum temperature to set state to good | `20` °C (`68` °F)
25//! `idle` | Maximum temperature to set state to idle | `45` °C (`113` °F)
26//! `info` | Maximum temperature to set state to info | `60` °C (`140` °F)
27//! `warning` | Maximum temperature to set state to warning. Beyond this temperature, state is set to critical | `80` °C (`176` °F)
28//! `chip` | Narrows the results to a given chip name. `*` may be used as a wildcard. | None
29//! `inputs` | Narrows the results to individual inputs reported by each chip. | None
30//!
31//! Action          | Description                               | Default button
32//! ----------------|-------------------------------------------|---------------
33//! `toggle_format` **DEPRECATED** | Toggles between `format` and `format_alt` | -
34//! `next_format`  | Switches to the next format in the list     | Left
35//! `prev_format`  | Switches to the previous format in the list | Right
36//!
37//! Placeholder | Value                                | Type   | Unit
38//! ------------|--------------------------------------|--------|--------
39//! `min`       | Minimum temperature among all inputs | Number | Degrees
40//! `average`   | Average temperature among all inputs | Number | Degrees
41//! `max`       | Maximum temperature among all inputs | Number | Degrees
42//!
43//! Note that when block is collapsed, no placeholders are provided.
44//!
45//! # Example
46//!
47//! ```toml
48//! [[block]]
49//! block = "temperature"
50//! format = " $icon $max max "
51//! format_alt = " $icon $min min, $max max, $average avg "
52//! interval = 10
53//! chip = "*-isa-*"
54//! ```
55//!
56//! # Icons Used
57//! - `thermometer` (`$icon`)
58
59use super::prelude::*;
60use crate::util::celsius_to_fahrenheit;
61use sensors::FeatureType::SENSORS_FEATURE_TEMP;
62use sensors::Sensors;
63use sensors::SubfeatureType::SENSORS_SUBFEATURE_TEMP_INPUT;
64
65const DEFAULT_GOOD: f64 = 20.0;
66const DEFAULT_IDLE: f64 = 45.0;
67const DEFAULT_INFO: f64 = 60.0;
68const DEFAULT_WARN: f64 = 80.0;
69
70#[derive(Deserialize, Debug, SmartDefault)]
71#[serde(default)]
72pub struct Config {
73    #[serde(flatten)]
74    pub formats: MaybeMultiFormatConfig,
75    #[default(5.into())]
76    pub interval: Seconds,
77    pub scale: TemperatureScale,
78    pub good: Option<f64>,
79    pub idle: Option<f64>,
80    pub info: Option<f64>,
81    pub warning: Option<f64>,
82    pub chip: Option<String>,
83    pub inputs: Option<Vec<String>>,
84}
85
86#[derive(Deserialize, Debug, SmartDefault, Clone, Copy, PartialEq, Eq)]
87#[serde(rename_all = "lowercase")]
88pub enum TemperatureScale {
89    #[default]
90    Celsius,
91    Fahrenheit,
92}
93
94impl TemperatureScale {
95    #[allow(clippy::wrong_self_convention)]
96    pub fn from_celsius(self, val: f64) -> f64 {
97        match self {
98            Self::Celsius => val,
99            Self::Fahrenheit => celsius_to_fahrenheit(val),
100        }
101    }
102
103    pub fn as_value(self, val: f64) -> Value {
104        match self {
105            Self::Celsius => Value::degrees_c(val),
106            Self::Fahrenheit => Value::degrees_f(val),
107        }
108    }
109}
110
111pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
112    let declare = |output: OutputPlan| output.icon("icon", IconChoices::one(icons::THERMOMETER));
113    let formats = config
114        .formats
115        .with_default(" $icon $average avg, $max max ")?;
116    BlockPlan::new(format_outputs(formats, declare))
117}
118
119pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
120    let mut actions = api.get_actions()?;
121    api.set_default_actions(&[
122        (MouseButton::Left, None, "next_format"),
123        (MouseButton::Right, None, "prev_format"),
124    ])?;
125
126    let mut formats = FormatRotation::new(plan)?;
127
128    let good = config
129        .good
130        .unwrap_or_else(|| config.scale.from_celsius(DEFAULT_GOOD));
131    let idle = config
132        .idle
133        .unwrap_or_else(|| config.scale.from_celsius(DEFAULT_IDLE));
134    let info = config
135        .info
136        .unwrap_or_else(|| config.scale.from_celsius(DEFAULT_INFO));
137    let warn = config
138        .warning
139        .unwrap_or_else(|| config.scale.from_celsius(DEFAULT_WARN));
140
141    loop {
142        let chip = config.chip.clone();
143        let inputs = config.inputs.clone();
144        let config_scale = config.scale;
145        let temp = tokio::task::spawn_blocking(move || {
146            let mut vals = Vec::new();
147            let sensors = Sensors::new();
148            let chips = match &chip {
149                Some(chip) => sensors
150                    .detected_chips(chip)
151                    .error("Failed to create chip iterator")?,
152                None => sensors.into_iter(),
153            };
154            for chip in chips {
155                for feat in chip {
156                    if *feat.feature_type() != SENSORS_FEATURE_TEMP {
157                        continue;
158                    }
159                    if let Some(inputs) = &inputs {
160                        let label = feat.get_label().error("Failed to get input label")?;
161                        if !inputs.contains(&label) {
162                            continue;
163                        }
164                    }
165                    for subfeat in feat {
166                        if *subfeat.subfeature_type() == SENSORS_SUBFEATURE_TEMP_INPUT
167                            && let Ok(value) = subfeat.get_value()
168                        {
169                            if (-100.0..=150.0).contains(&value) {
170                                vals.push(config_scale.from_celsius(value));
171                            } else {
172                                eprintln!("Temperature ({value}) outside of range ([-100, 150])");
173                            }
174                        }
175                    }
176                }
177            }
178            Ok(vals)
179        })
180        .await
181        .error("Failed to join tokio task")??;
182
183        let min_temp = temp
184            .iter()
185            .min_by(|a, b| a.partial_cmp(b).unwrap())
186            .cloned()
187            .unwrap_or(0.0);
188        let max_temp = temp
189            .iter()
190            .max_by(|a, b| a.partial_cmp(b).unwrap())
191            .cloned()
192            .unwrap_or(0.0);
193        let avg_temp = temp.iter().sum::<f64>() / temp.len() as f64;
194
195        let output = formats.current();
196        let mut widget = output.new_widget();
197
198        widget.state = match max_temp {
199            x if x <= good => State::Good,
200            x if x <= idle => State::Idle,
201            x if x <= info => State::Info,
202            x if x <= warn => State::Warning,
203            _ => State::Critical,
204        };
205
206        widget.set_values(map! {
207            "icon" => Value::icon_progression_bound(icons::THERMOMETER, max_temp, good, warn),
208            "average" => config_scale.as_value(avg_temp),
209            "min" => config_scale.as_value(min_temp),
210            "max" => config_scale.as_value(max_temp),
211        });
212
213        api.set_widget(widget)?;
214
215        select! {
216            _ = sleep(config.interval.0) => (),
217            _ = api.wait_for_update_request() => (),
218            Some(action) = actions.recv() => match action.as_ref() {
219                "next_format" | "toggle_format" => {
220                    formats.next();
221                }
222                "prev_format" => {
223                    formats.prev();
224                }
225                _ => (),
226            }
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn config(toml_str: &str) -> Config {
236        toml::from_str(toml_str).unwrap()
237    }
238
239    #[test]
240    fn plan_declares_thermometer_icon() {
241        let plan = prepare(&Config::default()).unwrap();
242        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
243        assert_eq!(ids, ["format"]);
244        let main = plan.output("format").unwrap();
245        assert_eq!(main.single_icon("icon").unwrap(), "thermometer");
246        assert!(main.format().contains_key("average"));
247    }
248
249    #[test]
250    fn every_configured_format_gets_an_output() {
251        let plan = prepare(&Config::default()).unwrap();
252        assert!(plan.output("format2").is_err());
253
254        let plan = prepare(&config(
255            r#"format = [" $icon $max max ", " $icon $min min "]"#,
256        ))
257        .unwrap();
258        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
259        assert_eq!(ids, ["format", "format2"]);
260        let alt = plan.output("format2").unwrap();
261        assert!(alt.format().contains_key("min"));
262        assert_eq!(alt.single_icon("icon").unwrap(), "thermometer");
263    }
264
265    #[test]
266    fn format_alt_still_produces_a_second_output() {
267        let plan = prepare(&config(r#"format_alt = " $icon $min min ""#)).unwrap();
268        let alt = plan.output("format2").unwrap();
269        assert!(alt.format().contains_key("min"));
270        assert_eq!(alt.single_icon("icon").unwrap(), "thermometer");
271    }
272}