Skip to main content

i3status_rs/blocks/
hueshift.rs

1//! Manage display temperature
2//!
3//! This block displays the current color temperature in Kelvin. When scrolling upon the block the color temperature is changed.
4//! A left click on the block sets the color temperature to `click_temp` that is by default to `6500K`.
5//! A right click completely resets the color temperature to its default value (`6500K`).
6//!
7//! # Configuration
8//!
9//! Key | Values | Default
10//! ----|--------|--------
11//! `format`      | A string to customise the output of this block. See below for available placeholders. | `" $temperature "`
12//! `step`        | The step color temperature is in/decreased in Kelvin. | `100`
13//! `hue_shifter` | Program used to control screen color. | Detect automatically
14//! `max_temp`    | Max color temperature in Kelvin. | `10000`
15//! `min_temp`    | Min color temperature in Kelvin. | `1000`
16//! `click_temp`  | Left click color temperature in Kelvin. | `6500`
17//!
18//! Placeholder           | Value                        | Type   | Unit
19//! ----------------------|------------------------------|--------|---------------
20//! `temperature`         | Current temperature          | Number | -
21//!
22//! Action             | Default button
23//! -------------------|---------------
24//! `set_click_temp`   | Left
25//! `reset`            | Right
26//! `temperature_up`   | Wheel Up
27//! `temperature_down` | Wheel Down
28//!
29//! # Available Hue Shifters
30//!
31//! Name                 | Supports
32//! ---------------------|---------
33//! `"redshift"`         | X11
34//! `"sct"`              | X11
35//! `"xsct"`             | X11
36//! `"gammastep"`        | X11 and Wayland
37//! `"wl_gammarelay"`    | Wayland
38//! `"wl_gammarelay_rs"` | Wayland
39//! `"wlsunset"`         | Wayland
40//!
41//! Note that at the moment, only [`wl_gammarelay`](https://github.com/jeremija/wl-gammarelay) and
42//! [`wl_gammarelay_rs`](https://github.com/MaxVerevkin/wl-gammarelay-rs)
43//! subscribe to the events and update the bar when the temperature is modified externally. Also,
44//! these are the only drivers at the moment that work under Wayland without flickering.
45//!
46//! # Example
47//!
48//! ```toml
49//! [[block]]
50//! block = "hueshift"
51//! hue_shifter = "redshift"
52//! step = 50
53//! click_temp = 3500
54//! ```
55//!
56//! A hard limit is set for the `max_temp` to `10000K` and the same for the `min_temp` which is `1000K`.
57//! The `step` has a hard limit as well, defined to `500K` to avoid too brutal changes.
58
59use super::prelude::*;
60use crate::subprocess::{spawn_process, spawn_shell};
61use crate::util::has_command;
62use futures::future::pending;
63
64#[derive(Deserialize, Debug, SmartDefault)]
65#[serde(deny_unknown_fields, default)]
66pub struct Config {
67    pub format: FormatConfig,
68    // TODO: Document once this option becomes useful
69    #[default(5.into())]
70    pub interval: Seconds,
71    #[default(10_000)]
72    pub max_temp: u16,
73    #[default(1_000)]
74    pub min_temp: u16,
75    // TODO: Remove (this option is undocumented)
76    #[default(6_500)]
77    pub current_temp: u16,
78    pub hue_shifter: Option<HueShifter>,
79    #[default(100)]
80    pub step: u16,
81    #[default(6_500)]
82    pub click_temp: u16,
83}
84
85pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
86    let format = config.format.with_default(" $icon $temperature ")?;
87    BlockPlan::new(vec![
88        OutputPlan::new("main", format).icon("icon", IconChoices::one(icons::HUESHIFT)),
89    ])
90}
91
92pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
93    let mut actions = api.get_actions()?;
94    api.set_default_actions(&[
95        (MouseButton::Left, None, "set_click_temp"),
96        (MouseButton::Right, None, "reset"),
97        (MouseButton::WheelUp, None, "temperature_up"),
98        (MouseButton::WheelDown, None, "temperature_down"),
99    ])?;
100
101    let output_main = plan.output("main")?;
102
103    // limit too big steps at 500K to avoid too brutal changes
104    let step = config.step.min(500);
105    let max_temp = config.max_temp.min(10_000);
106    let min_temp = config.min_temp.clamp(1_000, max_temp);
107
108    let hue_shifter = match config.hue_shifter {
109        Some(driver) => driver,
110        None => {
111            if has_command("wl-gammarelay-rs").await? {
112                HueShifter::WlGammarelayRs
113            } else if has_command("wl-gammarelay").await? {
114                HueShifter::WlGammarelay
115            } else if has_command("redshift").await? {
116                HueShifter::Redshift
117            } else if has_command("sct").await? {
118                HueShifter::Sct
119            } else if has_command("xsct").await? {
120                HueShifter::Xsct
121            } else if has_command("gammastep").await? {
122                HueShifter::Gammastep
123            } else if has_command("wlsunset").await? {
124                HueShifter::Wlsunset
125            } else {
126                return Err(Error::new("Could not detect driver program"));
127            }
128        }
129    };
130
131    let mut driver: Box<dyn HueShiftDriver> = match hue_shifter {
132        HueShifter::Redshift => Box::new(Redshift::new(config.interval)),
133        HueShifter::Sct => Box::new(Sct::new("sct", config.interval)),
134        HueShifter::Xsct => Box::new(Sct::new("xsct", config.interval)),
135        HueShifter::Gammastep => Box::new(Gammastep::new(config.interval)),
136        HueShifter::Wlsunset => Box::new(Wlsunset::new(config.interval)),
137        HueShifter::WlGammarelay => Box::new(WlGammarelayRs::new("wl-gammarelay").await?),
138        HueShifter::WlGammarelayRs => Box::new(WlGammarelayRs::new("wl-gammarelay-rs").await?),
139    };
140
141    let mut current_temp = driver.get().await?.unwrap_or(config.current_temp);
142
143    loop {
144        let mut widget = output_main.new_widget();
145        widget.set_values(map! {
146            "icon" => Value::icon(icons::HUESHIFT),
147            "temperature" => Value::number(current_temp)
148        });
149        api.set_widget(widget)?;
150
151        select! {
152            update = driver.receive_update() => {
153                current_temp = update?;
154            }
155            _ = api.wait_for_update_request() => {
156                if let Some(val) = driver.get().await? {
157                    current_temp = val;
158                }
159            }
160            Some(action) = actions.recv() => match action.as_ref() {
161                "set_click_temp" => {
162                    current_temp = config.click_temp;
163                    driver.update(current_temp).await?;
164                }
165                "reset" => {
166                    if max_temp > 6500 {
167                        current_temp = 6500;
168                        driver.reset().await?;
169                    } else {
170                        current_temp = max_temp;
171                        driver.update(current_temp).await?;
172                    }
173                }
174                "temperature_up" => {
175                    current_temp = (current_temp + step).min(max_temp);
176                    driver.update(current_temp).await?;
177                }
178                "temperature_down" => {
179                    current_temp = current_temp.saturating_sub(step).max(min_temp);
180                    driver.update(current_temp).await?;
181                }
182                _ => (),
183            }
184        }
185    }
186}
187
188#[derive(Deserialize, Debug, Clone, Copy)]
189#[serde(rename_all = "snake_case")]
190pub enum HueShifter {
191    Redshift,
192    Sct,
193    Xsct,
194    Gammastep,
195    Wlsunset,
196    WlGammarelay,
197    WlGammarelayRs,
198}
199
200#[async_trait]
201trait HueShiftDriver {
202    async fn get(&mut self) -> Result<Option<u16>>;
203    async fn update(&mut self, temp: u16) -> Result<()>;
204    async fn reset(&mut self) -> Result<()>;
205    async fn receive_update(&mut self) -> Result<u16>;
206}
207
208struct Redshift {
209    interval: Seconds,
210}
211
212impl Redshift {
213    fn new(interval: Seconds) -> Self {
214        Self { interval }
215    }
216}
217
218#[async_trait]
219impl HueShiftDriver for Redshift {
220    async fn get(&mut self) -> Result<Option<u16>> {
221        // TODO
222        Ok(None)
223    }
224    async fn update(&mut self, temp: u16) -> Result<()> {
225        spawn_process("redshift", &["-O", &temp.to_string(), "-P"])
226            .error("Failed to set new color temperature using redshift.")
227    }
228    async fn reset(&mut self) -> Result<()> {
229        spawn_process("redshift", &["-x"])
230            .error("Failed to set new color temperature using redshift.")
231    }
232    async fn receive_update(&mut self) -> Result<u16> {
233        sleep(self.interval.0).await;
234        // self.get().await
235        pending().await
236    }
237}
238
239struct Sct {
240    cmd: &'static str,
241    interval: Seconds,
242}
243
244impl Sct {
245    fn new(cmd: &'static str, interval: Seconds) -> Self {
246        Self { cmd, interval }
247    }
248}
249
250#[async_trait]
251impl HueShiftDriver for Sct {
252    async fn get(&mut self) -> Result<Option<u16>> {
253        // TODO
254        Ok(None)
255    }
256    async fn update(&mut self, temp: u16) -> Result<()> {
257        spawn_shell(&format!("{0} {temp} >/dev/null 2>&1", self.cmd))
258            .error("Failed to set new color temperature using sct.")
259    }
260    async fn reset(&mut self) -> Result<()> {
261        spawn_process(self.cmd, &["0"]).error("Failed to set new color temperature using sct.")
262    }
263    async fn receive_update(&mut self) -> Result<u16> {
264        sleep(self.interval.0).await;
265        // self.get().await
266        pending().await
267    }
268}
269
270struct Gammastep {
271    interval: Seconds,
272}
273
274impl Gammastep {
275    fn new(interval: Seconds) -> Self {
276        Self { interval }
277    }
278}
279
280#[async_trait]
281impl HueShiftDriver for Gammastep {
282    async fn get(&mut self) -> Result<Option<u16>> {
283        // TODO
284        Ok(None)
285    }
286    async fn update(&mut self, temp: u16) -> Result<()> {
287        spawn_shell(&format!("pkill gammastep; gammastep -O {temp} -P &",))
288            .error("Failed to set new color temperature using gammastep.")
289    }
290    async fn reset(&mut self) -> Result<()> {
291        spawn_process("gammastep", &["-x"])
292            .error("Failed to set new color temperature using gammastep.")
293    }
294    async fn receive_update(&mut self) -> Result<u16> {
295        sleep(self.interval.0).await;
296        // self.get().await
297        pending().await
298    }
299}
300
301struct Wlsunset {
302    interval: Seconds,
303}
304
305impl Wlsunset {
306    fn new(interval: Seconds) -> Self {
307        Self { interval }
308    }
309}
310
311#[async_trait]
312impl HueShiftDriver for Wlsunset {
313    async fn get(&mut self) -> Result<Option<u16>> {
314        // TODO
315        Ok(None)
316    }
317    async fn update(&mut self, temp: u16) -> Result<()> {
318        // wlsunset does not have a oneshot option, so set both day and
319        // night temperature. wlsunset dose not allow for day and night
320        // temperatures to be the same, so increment the day temperature.
321        spawn_shell(&format!(
322            "pkill wlsunset; wlsunset -T {} -t {} &",
323            temp + 1,
324            temp
325        ))
326        .error("Failed to set new color temperature using wlsunset.")
327    }
328    async fn reset(&mut self) -> Result<()> {
329        // wlsunset does not have a reset option, so just kill the process.
330        // Trying to call wlsunset without any arguments uses the defaults:
331        // day temp: 6500K
332        // night temp: 4000K
333        // latitude/longitude: NaN
334        //     ^ results in sun_condition == POLAR_NIGHT at time of testing
335        // With these defaults, this results in the the color temperature
336        // getting set to 4000K.
337        spawn_process("pkill", &["wlsunset"])
338            .error("Failed to set new color temperature using wlsunset.")
339    }
340    async fn receive_update(&mut self) -> Result<u16> {
341        sleep(self.interval.0).await;
342        // self.get().await
343        pending().await
344    }
345}
346
347struct WlGammarelayRs {
348    proxy: WlGammarelayRsBusProxy<'static>,
349    updates: zbus::proxy::PropertyStream<'static, u16>,
350}
351
352impl WlGammarelayRs {
353    async fn new(cmd: &str) -> Result<Self> {
354        // Make sure the daemon is running
355        spawn_process(cmd, &[]).error("Failed to start wl-gammarelay daemon")?;
356        sleep(Duration::from_millis(100)).await;
357
358        let conn = crate::util::new_dbus_connection().await?;
359        let proxy = WlGammarelayRsBusProxy::new(&conn)
360            .await
361            .error("Failed to create wl-gammarelay-rs DBus proxy")?;
362        let updates = proxy.receive_temperature_changed().await;
363        Ok(Self { proxy, updates })
364    }
365}
366
367#[async_trait]
368impl HueShiftDriver for WlGammarelayRs {
369    async fn get(&mut self) -> Result<Option<u16>> {
370        let value = self
371            .proxy
372            .temperature()
373            .await
374            .error("Failed to get temperature")?;
375        Ok(Some(value))
376    }
377    async fn update(&mut self, temp: u16) -> Result<()> {
378        self.proxy
379            .set_temperature(temp)
380            .await
381            .error("Failed to set temperature")
382    }
383    async fn reset(&mut self) -> Result<()> {
384        self.update(6500).await
385    }
386    async fn receive_update(&mut self) -> Result<u16> {
387        let update = self.updates.next().await.error("No next update")?;
388        update.get().await.error("Failed to get temperature")
389    }
390}
391
392#[zbus::proxy(
393    interface = "rs.wl.gammarelay",
394    default_service = "rs.wl-gammarelay",
395    default_path = "/"
396)]
397trait WlGammarelayRsBus {
398    /// Brightness property
399    #[zbus(property)]
400    fn brightness(&self) -> zbus::Result<f64>;
401    #[zbus(property)]
402    fn set_brightness(&self, value: f64) -> zbus::Result<()>;
403
404    /// Temperature property
405    #[zbus(property)]
406    fn temperature(&self) -> zbus::Result<u16>;
407    #[zbus(property)]
408    fn set_temperature(&self, value: u16) -> zbus::Result<()>;
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn plan_declares_a_single_output_with_its_icon() {
417        let plan = prepare(&Config::default()).unwrap();
418        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
419        assert_eq!(ids, ["main"]);
420        let main = plan.output("main").unwrap();
421        assert_eq!(main.single_icon("icon").unwrap(), "hueshift");
422        assert!(main.format().contains_key("temperature"));
423    }
424
425    #[test]
426    fn configured_format_is_installed() {
427        let config = Config {
428            format: " $temperature ".parse().unwrap(),
429            ..Config::default()
430        };
431        let plan = prepare(&config).unwrap();
432        let main = plan.output("main").unwrap();
433        assert!(!main.format().contains_key("icon"));
434        assert!(main.format().contains_key("temperature"));
435    }
436}