家裡有一臺半閒置的 12 年的 Mac Mini,之前偶爾跑點 Docker 的東西,順便還有把一臺超舊的印表機共享到局域網裡。不過想想應該拿它做點別的事,比如裝個 Linux 然後搭上 v2ray 做軟路由實現透明代理,然後再裝個 Docker 偶爾測試自己寫的 Linux 的東西~
不過這一篇 post 並不是寫如何用 v2ray 在 Linux 上搭軟路由,而是想起現在的軟路由用的是 Raspberry Pi 4。Raspberry Pi 4 在實際使用的時候還是不錯的,但是發熱量比較大,以及 softirq 看起來略有點爆炸 233333
![](/wp-content/uploads/2019/12/breezin-pi-softirq.png)
因為打算用 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_min
和 fan1_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 有一個名為 fan1
和 fan2
的風扇,那麼在直接 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
測試一下效果~
![](/wp-content/uploads/2019/12/breezin-cargo-build.png)
![](/wp-content/uploads/2019/12/breezin-test.png)
看起來木有問題~把編譯好的放到 /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