从零开始的 Rust 学习笔记(10) —— Breezin

家裡有一臺半閒置的 12 年的 Mac Mini,之前偶爾跑點 Docker 的東西,順便還有把一臺超舊的印表機共享到局域網裡。不過想想應該拿它做點別的事,比如裝個 Linux 然後搭上 v2ray 做軟路由實現透明代理,然後再裝個 Docker 偶爾測試自己寫的 Linux 的東西~

不過這一篇 post 並不是寫如何用 v2ray 在 Linux 上搭軟路由,而是想起現在的軟路由用的是 Raspberry Pi 4。Raspberry Pi 4 在實際使用的時候還是不錯的,但是發熱量比較大,以及 softirq 看起來略有點爆炸 233333

因為打算用 Raspberry Pi 4 做點別的專案,那麼軟路由透明代理的 workload 就交給 Mac Mini 好啦,再寫個程式手動控制一下 Mac Mini 的風扇轉速~這樣就不會因為 workload 比較大,然後晚上風扇轉速太高影響睡眠? 白天的時候倒是基本無所謂。測試的時候發現 3000-3300 RPM 幾乎聽不到聲音,同時也比最低速 1800 RPM 高出一截,不太會因為過熱而出現問題~(╹ڡ╹)

那麼名字就叫 Breezin 好啦(一邊聽彩彩的 Breezin' 一邊寫~

當然,僅僅說完成功能的話,Shell Script 都完全足夠,但是既然是正好在玩 Rust 的話,那就用 Rust 寫來玩玩吧(*^3^)

在 Mac Mini 上安裝了 Ubuntu 18.04 LTS 之後,SMC 報告的風扇和溫度等資訊都被對映在了 /sys/devices/platform/applesmc.768

➜ ~ ls /sys/devices/platform/applesmc.768/fan*
-r--r--r-- 1 root root 4096 12  3 14:52 /sys/devices/platform/applesmc.768/fan1_input
-r--r--r-- 1 root root 4096 12  3 14:52 /sys/devices/platform/applesmc.768/fan1_label
-rw-r--r-- 1 root root 4096 12  3 14:53 /sys/devices/platform/applesmc.768/fan1_manual
-r--r--r-- 1 root root 4096 12  3 14:52 /sys/devices/platform/applesmc.768/fan1_max
-rw-r--r-- 1 root root 4096 12  3 14:54 /sys/devices/platform/applesmc.768/fan1_min
-rw-r--r-- 1 root root 4096 12  3 14:53 /sys/devices/platform/applesmc.768/fan1_output
-r--r--r-- 1 root root 4096 12  3 14:52 /sys/devices/platform/applesmc.768/fan1_safe

這裡可以看到實際可寫入的只有 fan1_manual, fan1_minfan1_output

fan1_label 裡面儲存了風扇的名字。fan1_manual 實際上會被解釋成一個 Boolean 值,0 代表系統控制,1 代表手動設定。

fan1_min 是對應風扇的最低的 RPM,這個是我們可以控制的。相應的 fan1_max 則是最大轉速,但是不能限制風扇的最大轉速。

fan1_input 是表示當前風扇報告的 RPM,但是是一個只讀的量。fan1_output 表示需要對應風扇達到的 RPM,當 fan1_manual 的值為 1 時有效,否則寫入之後也會被系統覆蓋(即自動控制轉速)。

最後的 fan1_safe 看起來大概是該風扇的最低?安全轉速,然而雖然寫了可讀,實際測試的時候並不可讀(也許是 Ubuntu 下 SMC 驅動的問題?)。

➜ ~ cat /sys/devices/platform/applesmc.768/fan1_safe
cat: /sys/devices/platform/applesmc.768/fan1_safe: Invalid argument

在知道了這些對應的對映之後,想法就是在使用者請求資訊的時候,去 glob /sys/devices/platform/applesmc.768/fan*,然後做成 JSON 資料返回。在使用者設定的風扇轉速的時候,就寫入到對應的 output 裡面,並將 manual 設定為 1

於是設想就是做一個 HTTP API,假如 IP 是 10.0.1.2,服務執行在 2275 埠的話,那麼要做的就是如下 2 個 API

獲取所有風扇的資訊

HTTP Method: GET
API URL: http://10.0.1.2:2275/get
Optional Parameter: name=\$name

比如我的 Mac 有一個名為 fan1fan2 的風扇,那麼在直接 GET 請求這個 API http://10.0.1.2:2275/get 的時候就會返回

{
    "fan1_input": "1799",
    "fan1_label": "Exhaust",
    "fan1_manual": "0",
    "fan1_max": "5500",
    "fan1_min": "1800",
    "fan1_output": "1800",
    "fan2_input": "1999",
    "fan2_label": "Imagined",
    "fan2_manual": "0",
    "fan2_max": "6500",
    "fan2_min": "2000",
    "fan2_output": "2000",
    "status": "0"
}

如果要請求一個特定的風扇的資訊的話,比如 fan1 的話,則是傳送 GET 請求到 http://10.0.1.2:2275/get?name=fan1

{
    "fan1_input": "1799",
    "fan1_label": "Exhaust",
    "fan1_manual": "0",
    "fan1_max": "5500",
    "fan1_min": "1800",
    "fan1_output": "1800",
    "status": "0"
}

話說也考慮了一下 RESTful API,其實就是從 parse query 變成 parse URI。要是想的話,就是做成 http://10.0.1.2/fan 取回所有風扇的資訊;要取回某個風扇的話,就是 http://10.0.1.2/fan/1。不過這裡就先這樣好啦( ´▽`)

設定風扇轉速

HTTP Method: GET
API URL: http://10.0.1.2:2275/set
Required Parameter: name=\$name&value=\$value

於是設定 fan1 的轉速到 3000 RPM 就是傳送 GET 請求到 http://10.0.1.2/set?name=fan1&value=3000

如果需要讓 fan1 回到自動控制轉速的話,那麼就是 http://10.0.1.2/set?name=fan1&value=auto

用 RESTful API 的話,這裡理論上應該使用 PUT 方法才比較符合規範,不過那樣遠端操作起來就稍微麻煩一些,於是就乾脆只做成 HTTP API 啦~

開始啃螃蟹 ? 吧~

先拿 cargo 建立專案吧~

cargo new breezin

在 HTTP 庫的選擇上,使用了 hyper,完全從頭寫一個 HTTP 的處理就太累了╮( ̄▽ ̄"")╭ 於是 Cargo.toml 如下~

[package]
name = "breezin"
version = "0.1.0"
authors = ["Ryza<[data deleted]>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = "2.33.0"
lazy_static = "1.4.0"
hyper = "0.12"
futures = "0.1"
url = "1.7.2"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
glob = "0.3"
regex = "1"

然後是 src/main.rs (⁎⁍̴̛ᴗ⁍̴̛⁎)

#![deny(warnings)]

#[macro_use]
extern crate lazy_static;

use clap::{Arg, App};
use futures::{future, Future};
use glob::glob;
use hyper::{Body, Method, Request, Response, Server};
use hyper::service::service_fn;
use regex::Regex;
use url::Url;
use std::collections::HashMap;
use std::io;
use std::io::{Read, Write};
use std::fs::File;

// using `lazy_static` to ensure that all regex is compiled exactly once
// https://docs.rs/regex/1.3.1/regex/#example-avoid-compiling-the-same-regex-in-a-loop
lazy_static! {
    static ref FANRE: Regex = Regex::new(r"(^fan\d+)").unwrap();
}

// SMC Fan location in Ubuntu Linux
static APPLESMC_FAN: &str = "/sys/devices/platform/applesmc.768";

type GenericError = Box<dyn std::error::Error + Send + Sync>;
type ResponseFuture = Box<dyn Future<Item=Response<Body>, Error=GenericError> + Send>;

/// Read all content from the given file.
///
/// # Examples
///
/// ```
/// match read_from_file("/PATH/TO/FILE") {
///     Ok(content) => println!("{}", content),
///     Err(e) => println!("[ERROR] {:?}", e),
/// }
/// ```
fn read_from_file(file: &String) -> Result<String, io::Error> {
    let mut s = String::new();
    File::open(file)?.read_to_string(&mut s)?;
    Ok(s)
}

/// Read all status from SMC fan
///
/// # Examples
///
/// ```
/// match read_smc_fan() {
///     Ok(readouts) => println!("{:?}", readouts),
///     Err(e) => println!("[ERROR] {:?}", e),
/// }
/// ```
fn read_smc_fan() -> Result<serde_json::Value, io::Error> {
    // init a JSON dict
    let mut readouts : serde_json::Value = serde_json::from_str("{}")?;

    // generate glob string
    let glob_string = &format!("{}/fan*", APPLESMC_FAN);
    // glob all files whose name starts with 'fan'
    for entry in glob(glob_string).expect("[ERROR] Failed to read fan status") {
        // match each entry
        match entry {
            Ok(matched) => {
                // get path to the file
                let filepath = matched.to_str().unwrap().to_string();
                // get its name
                let filename = matched.file_name().unwrap().to_str().unwrap().to_string();
                // read its content
                match read_from_file(&filepath) {
                    Ok(readout) => {
                        // save in the JSON dict
                        readouts[filename] = serde_json::Value::from(readout.trim().to_string());
                    },
                    Err(_) => ()
                }
            },
            Err(e) => {
                // let's handle possible error for entry here
                println!("[ERROR] {:?}", e);
            }
        }
    }
    
    // return all readouts
    Ok(readouts)
}

/// stringify the JSON result and put into box
///
/// # Examples
///
/// ```
/// let data : serde_json::Value = serde_json::from_str("{}").unwrap();
/// stringify_result(&data)
/// ```
fn stringify_result(result: &serde_json::Value) -> ResponseFuture {
    // stringify the JSON dict
    let json = serde_json::to_string_pretty(result).unwrap();
    // return the result
    Box::new(future::ok(Response::new(Body::from(json))))
}

/// Handle HTTP request to `/get` and `/get?name={name}`
fn api_get_response(req: Request<Body>) -> ResponseFuture {
    // try to parse the parameters into pairs
    let query: HashMap<_, _> = Url::parse(&format!("http://localhost{}", &req.uri())).unwrap().query_pairs().into_owned().collect();
    
    // read the status from smc
    match read_smc_fan() {
        Ok(mut readouts) => {
            // if everything goes right
            // let's check whether the request demands a specific value or not
            if let Some(name) = query.get("name") {
                // if yes
                // init another JSON dict
                let mut readout : serde_json::Value = serde_json::from_str("{}").unwrap();
                // try to get the requested value
                if !readouts[name].is_null() {
                    // if we do have that
                    // save the readout value
                    readout[name] = serde_json::Value::from(readouts[name].clone());
                    // set the status to 0
                    readout["status"] = serde_json::Value::from("0");
                } else {
                    // otherwise report that no such value
                    // set the status to -1
                    readout["status"] = serde_json::Value::from("-1");
                    // set the reason
                    readout["reason"] = serde_json::Value::from(format!("no such value named: {}", name));
                }
                // stringify the JSON dict and return the result
                stringify_result(&readout)
            } else {
                // otherwise output all readouts
                // set the status to 0
                readouts["status"] = serde_json::Value::from("0");
                // stringify the JSON dict and return the result
                stringify_result(&readouts)
            }
        },
        Err(e) => {
            // if some error occurred
            // log this error
            eprintln!("[ERROR] {:?}", e);
            // init another JSON dict for reporting the error
            let mut error : serde_json::Value = serde_json::from_str("{}").unwrap();
            // set the status to -2
            error["status"] = serde_json::Value::from("-2");
            // set the reason
            error["reason"] = serde_json::Value::from(format!("{:?}", e));
            // stringify the JSON dict and return the result
            stringify_result(&error)
        }
    }
}

/// Read safe or max rpm for smc fan
///
/// # Examples
///
/// ```
/// match read_smc_safe_or_max_rpm("fan1") {
///     Ok(safe_or_max_rpm) => println!("safe_or_max_rpm: {}", safe_or_max_rpm),
///     Err(e) => eprintln!("[ERROR] {:?}", e),
/// }
/// ```
fn read_smc_safe_or_max_rpm(fan: &str) -> Result<u32, String> {
    // safe rpm file name
    let safe_file = &format!("{}/{}_safe", APPLESMC_FAN, fan);
    // try to read safe rpm for this fan
    match read_from_file(safe_file) {
        // parse into integer
        Ok(safe_rpm_str) => {
            match safe_rpm_str.trim().to_string().parse::<u32>() {
                Ok(safe_rpm) => Ok(safe_rpm),
                Err(_) => Err("safe rpm readout is not a number".to_string())
            }
        },
        Err(_) => {
            // max rpm file name
            let max_file = &format!("{}/{}_max", APPLESMC_FAN, fan);
            // try to read max rpm for this fan
            match read_from_file(max_file) {
                Ok(max_rpm_str) => {
                    match max_rpm_str.trim().to_string().parse::<u32>() {
                        Ok(max_rpm) => Ok(max_rpm),
                        Err(_) => Err("max rpm readout is not a number".to_string())
                    }
                },
                Err(_) => Err(format!("cannot read neither safe nor max rpm for `{}`", fan))
            }
        }
    }
}

/// Write the requested `rpm` for `fan`
///
/// # Examples
///
/// ```
/// match write_smc_fan("fan1", 2000) {
///     Ok(_) => println!("succeeded"),
///     Err(e) => eprintln!("[ERROR] {:?}", e),
/// }
/// ```
fn write_smc_fan(fan: &str, rpm: u32) -> Result<(), io::Error> {
    // try to enable manual mode
    set_smc_fan_manual(fan, true)?;
    // output rpm file name
    let min_file = &format!("{}/{}_output", APPLESMC_FAN, fan);
    // create output file write the rpm
    File::create(min_file)?.write_all(format!("{}\n", rpm).as_bytes())?;
    Ok(())
}

/// Control maunal mode status of requested `fan`
///
/// # Examples
///
/// ```
/// match set_smc_fan_manual("fan1", true) {
///     Ok(_) => println!("succeeded"),
///     Err(e) => eprintln!("[ERROR] {:?}", e),
/// }
/// ```
fn set_smc_fan_manual(fan: &str, manual: bool) -> Result<(), io::Error> {
    // manual file name
    let min_file = &format!("{}/{}_manual", APPLESMC_FAN, fan);
    match manual {
        // enable manual mode
        true => File::create(min_file)?.write_all("1\n".as_bytes())?,
        // disable manual mode
        false => File::create(min_file)?.write_all("0\n".as_bytes())?
    }
    Ok(())
}

/// Handle HTTP request to `/set` and `/set?name={name}&value={value}`
fn api_set_response(req: Request<Body>) -> ResponseFuture {
    // parse the parameters into pairs
    let query: HashMap<_, _> = Url::parse(&format!("http://localhost{}", &req.uri())).unwrap().query_pairs().into_owned().collect();
    // init JSON dict
    let mut result : serde_json::Value = serde_json::from_str("{}").unwrap();
    // name and value must exists
    // and value should be a positive integer
    if let Some(name) = query.get("name") {
        // try to find out which fan will we modify
        if let Some(matched) = FANRE.captures(name) {
            // get the name of the smc fan
            let fan = matched.get(1).unwrap().as_str();
            // get the value in string
            if let Some(value) = query.get("value") {
                // check whether `value` set to `auto`
                if value == "auto" {
                    match set_smc_fan_manual(fan, false) {
                        Ok(_) => {
                            // set the status to 0
                            result["status"] = serde_json::Value::from("0");
                        },
                        Err(e) => {
                            // set the status to -11
                            result["status"] = serde_json::Value::from("-11");
                            // set the reason
                            result["reason"] = serde_json::Value::from(format!("{:?}", e));
                        }
                    }
                } else {
                    // parse value into integer
                    match value.parse::<u32>() {
                        // if succeeded
                        Ok(rpm) => {
                            // read its safe or max rpm
                            match read_smc_safe_or_max_rpm(fan) {
                                // if we have either safe or max rpm
                                Ok(safe_or_max_rpm) => {
                                    // and the requested rpm is in range
                                    if rpm > 0 && rpm <= safe_or_max_rpm {
                                        // try to write to it
                                        match write_smc_fan(fan, rpm) {
                                            Ok(_) => {
                                                // set the status to 0
                                                result["status"] = serde_json::Value::from("0");
                                            },
                                            Err(e) => {
                                                // set the status to -10
                                                result["status"] = serde_json::Value::from("-10");
                                                // set the reason
                                                result["reason"] = serde_json::Value::from(format!("{:?}", e));
                                            }
                                        }
                                    } else {
                                        // set the status to -7
                                        result["status"] = serde_json::Value::from("-7");
                                        // set the reason
                                        result["reason"] = serde_json::Value::from(format!("value `{}` exceeded safe range", value));
                                    }
                                },
                                Err(e) => {
                                    // set the status to -9
                                    result["status"] = serde_json::Value::from("-9");
                                    // set the reason
                                    result["reason"] = serde_json::Value::from(e);
                                }
                            }
                        },
                        Err(_) => {
                            // set the status to -6
                            result["status"] = serde_json::Value::from("-6");
                            // set the reason
                            result["reason"] = serde_json::Value::from(format!("value `{}` is not an integer", value));
                        }
                    }
                }
            } else {
                // set the status to -4
                result["status"] = serde_json::Value::from("-4");
                // set the reason
                result["reason"] = serde_json::Value::from("missing `value` parameter");
            }
        } else {
            // set the status to -8
            result["status"] = serde_json::Value::from("-8");
            // set the reason
            result["reason"] = serde_json::Value::from("the name of smc fan in Linux should be `^fan\\d+`");
        }
    } else {
        // set the status to -3
        result["status"] = serde_json::Value::from("-3");
        // set the reason
        result["reason"] = serde_json::Value::from("missing `name` parameter");
    }
    // stringify the JSON dict and return the result
    stringify_result(&result)
}

/// breezin service
fn breezin(req: Request<Body>) -> ResponseFuture {
    // match the HTTP request
    match (req.method(), req.uri().path()) {
        // get fan status
        (&Method::GET, "/get") => {
            api_get_response(req)
        },
        (&Method::GET, "/set") => {
            api_set_response(req)
        }
        _ => {
            // init JSON dict for reporting error
            let mut error : serde_json::Value = serde_json::from_str("{}").unwrap();
            // set the status to -5
            error["status"] = serde_json::Value::from("-5");
            // set the reason
            error["reason"] = serde_json::Value::from("only `/set` and `/get` are supported currently");
            // stringify the JSON dict and return the result
            stringify_result(&error)
        }
    }
}

/// Parse command line arguments
fn parseargs() -> (String, String) {
    // https://github.com/clap-rs/clap#quick-example
    let matches = App::new("breezin")
        .version("1.0")
        .author("Ryza<[data deleted]>")
        .about("Remote control fan speed on a Mac which runs Linux")
        .arg(Arg::with_name("bind")
            .short("b")
            .long("bind")
            .value_name("IP")
            .help("binding IP address")
            .required(true)
        )
        .arg(Arg::with_name("port")
            .short("p")
            .long("port")
            .value_name("PORT")
            .help("listen at port")
            .required(true)
        )
        .get_matches();

    // directly use `unwrap()` because they were set to be required
    let bind = matches.value_of("bind").unwrap().to_string();
    let port = matches.value_of("port").unwrap().to_string();
    
    (bind, port)
}

fn main() {
    // parse command line args
    let (bind, port) = parseargs();
    
    // try to parse binding ip and port
    let addr = format!("{}:{}", bind, port).parse().unwrap();

    hyper::rt::run(future::lazy(move || {
        // bind IP address and serve breezin service!
        let server = Server::bind(&addr)
            .serve(move || {
                service_fn(move |req| {
                    // create breezin service
                    breezin(req)
                }
            )})
            .map_err(|e| eprintln!("[ERROR] server error: {}", e));

        println!("[INFO] Breezin' on http://{}", addr);

        server
    }));
}

接著就是編譯~

cargo build --release

測試一下效果~

看起來木有問題~把編譯好的放到 /usr/local/bin/ 下,

sudo mv target/release/breezin /usr/local/bin

最後寫個 systemd 的啟動項吧~

cat <<EOF | sudo tee /etc/systemd/system/breezin.service
[Unit]
Description=Breezin Service
After=network-online.target
[Service]
User=root
ExecStart=/usr/local/bin/breezin --bind 0.0.0.0 --port 2275
RestartSec=2
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable breezin
sudo service breezin start

Leave a Reply

Your email address will not be published. Required fields are marked *

ten − 6 =