1use super::SystemModule;
4use crate::taskserver::Control;
5use crate::{config, taskserver};
6use anyhow::{Result, anyhow, ensure};
7use bitflags::bitflags;
8use chrono::{DateTime, Local, NaiveTime};
9use log::info;
10use serde::{Deserialize, Serialize};
11use std::collections::VecDeque;
12use tokio::{process::Command, select};
13
14const HISTORY_QUEUE_SIZE: usize = 60 * 1024 * 2;
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct HealthConfig {
22 enabled: bool,
24 debug_exec_once: bool,
26}
27
28pub struct Health {
30 config: HealthConfig,
32 wakeup_list_check: Vec<NaiveTime>,
34 wakeup_list_tweet: Vec<NaiveTime>,
36 history: VecDeque<HistoryEntry>,
38}
39
40impl Health {
41 pub fn new(
45 wakeup_list_check: Vec<NaiveTime>,
46 wakeup_list_tweet: Vec<NaiveTime>,
47 ) -> Result<Self> {
48 info!("[health] initialize");
49
50 let config: HealthConfig = config::get(|cfg| cfg.health.clone());
51
52 Ok(Health {
53 config,
54 wakeup_list_check,
55 wakeup_list_tweet,
56 history: VecDeque::with_capacity(HISTORY_QUEUE_SIZE),
57 })
58 }
59
60 async fn check_task(&mut self, _ctrl: &Control) -> Result<()> {
63 let cpu_info = get_cpu_info().await?;
64 let mem_info = get_mem_info().await?;
65 let disk_info = get_disk_info().await?;
66
67 let timestamp = Local::now();
68 let enrty = HistoryEntry {
69 timestamp,
70 cpu_info,
71 mem_info,
72 disk_info,
73 };
74
75 debug_assert!(self.history.len() <= HISTORY_QUEUE_SIZE);
76 while self.history.len() >= HISTORY_QUEUE_SIZE {
78 self.history.pop_front();
79 }
80 self.history.push_back(enrty);
82
83 Ok(())
84 }
85
86 async fn tweet_task(&self, ctrl: &Control) -> Result<()> {
89 if let Some(entry) = self.history.back() {
90 let HistoryEntry {
91 cpu_info,
92 mem_info,
93 disk_info,
94 ..
95 } = entry;
96
97 let mut text = String::new();
98
99 text.push_str(&format!("CPU: {:.1}%", cpu_info.cpu_percent_total));
100
101 if let Some(temp) = cpu_info.temp {
102 text.push_str(&format!("\nCPU Temp: {temp:.1}'C"));
103 }
104
105 text.push_str(&format!(
106 "\nMemory: {:.1}/{:.1} MB Avail ({:.1}%)",
107 mem_info.avail_mib,
108 mem_info.total_mib,
109 100.0 * mem_info.avail_mib / mem_info.total_mib,
110 ));
111
112 text.push_str(&format!(
113 "\nDisk: {:.1}/{:.1} GB Avail ({:.1}%)",
114 disk_info.avail_gib,
115 disk_info.total_gib,
116 100.0 * disk_info.avail_gib / disk_info.total_gib,
117 ));
118
119 let mut twitter = ctrl.sysmods().twitter.lock().await;
120 twitter.tweet(&text).await?;
121 }
122
123 Ok(())
124 }
125
126 async fn check_task_entry(ctrl: Control) -> Result<()> {
129 let mut health = ctrl.sysmods().health.lock().await;
131 health.check_task(&ctrl).await
132 }
134
135 async fn tweet_task_entry(ctrl: Control) -> Result<()> {
138 select! {
140 _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {}
141 _ = ctrl.wait_cancel_rx() => {
142 info!("[health-tweet] task cancel");
143 return Ok(());
144 }
145 }
146
147 let health = ctrl.sysmods().health.lock().await;
149 health.tweet_task(&ctrl).await
150 }
152}
153
154impl SystemModule for Health {
155 fn on_start(&mut self, ctrl: &Control) {
156 info!("[health] on_start");
157 if self.config.enabled {
158 if self.config.debug_exec_once {
159 taskserver::spawn_oneshot_task(ctrl, "health-check", Health::check_task_entry);
160 taskserver::spawn_oneshot_task(ctrl, "health-tweet", Health::tweet_task_entry);
161 } else {
162 taskserver::spawn_periodic_task(
163 ctrl,
164 "health-check",
165 &self.wakeup_list_check,
166 Health::check_task_entry,
167 );
168 taskserver::spawn_periodic_task(
169 ctrl,
170 "health-tweet",
171 &self.wakeup_list_tweet,
172 Health::tweet_task_entry,
173 );
174 }
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
181struct HistoryEntry {
182 #[allow(dead_code)]
184 timestamp: DateTime<Local>,
185 cpu_info: CpuInfo,
187 mem_info: MemInfo,
189 disk_info: DiskInfo,
191}
192
193#[derive(Debug, Clone, Copy)]
195pub struct CpuInfo {
196 pub cpu_percent_total: f64,
198 pub temp: Option<f64>,
201}
202
203#[derive(Debug, Clone, Copy)]
205pub struct MemInfo {
206 total_mib: f64,
208 avail_mib: f64,
210}
211
212#[derive(Debug, Clone, Copy)]
214pub struct DiskInfo {
215 total_gib: f64,
217 avail_gib: f64,
219}
220
221pub async fn get_cpu_info() -> Result<CpuInfo> {
225 let buf = tokio::fs::read("/proc/stat").await?;
226 let text = String::from_utf8_lossy(&buf);
227
228 let mut cpu_percent_total = None;
247 let mut cpu_percent_list = vec![];
248 for line in text.lines() {
249 let mut name = None;
250 let mut user = None;
251 let mut nice = None;
252 let mut system = None;
253 let mut idle = None;
254 for (col_no, elem) in line.split_ascii_whitespace().enumerate() {
255 match col_no {
256 0 => name = Some(elem),
257 1 => user = Some(elem),
258 2 => nice = Some(elem),
259 3 => system = Some(elem),
260 4 => idle = Some(elem),
261 _ => (),
262 }
263 }
264 if name.is_none() || !name.unwrap().starts_with("cpu") {
266 continue;
267 }
268
269 let user: u64 = user.ok_or_else(|| anyhow!("parse error"))?.parse()?;
270 let nice: u64 = nice.ok_or_else(|| anyhow!("parse error"))?.parse()?;
271 let system: u64 = system.ok_or_else(|| anyhow!("parse error"))?.parse()?;
272 let idle: u64 = idle.ok_or_else(|| anyhow!("parse error"))?.parse()?;
273 let total = user + nice + system + idle;
274 let value = (total - idle) as f64 / total as f64;
275 if name == Some("cpu") {
276 cpu_percent_total = Some(value);
277 } else {
278 cpu_percent_list.push(value);
279 }
280 }
281
282 ensure!(cpu_percent_total.is_some());
283 ensure!(!cpu_percent_list.is_empty());
284 let cpu_percent_total = cpu_percent_total.unwrap();
285
286 let temp = get_cpu_temp().await?;
287
288 Ok(CpuInfo {
289 cpu_percent_total,
290 temp,
291 })
292}
293
294pub async fn get_mem_info() -> Result<MemInfo> {
298 let mut cmd = Command::new("free");
299 let output = cmd.output().await?;
300 ensure!(output.status.success(), "free command failed");
301
302 let stdout = String::from_utf8_lossy(&output.stdout);
307 let mut total = None;
308 let mut avail = None;
309 for (line_no, line) in stdout.lines().enumerate() {
310 if line_no != 1 {
311 continue;
312 }
313 for (col_no, elem) in line.split_ascii_whitespace().enumerate() {
314 match col_no {
315 1 => total = Some(elem),
316 6 => avail = Some(elem),
317 _ => (),
318 }
319 }
320 break;
321 }
322 let total = total.ok_or_else(|| anyhow!("parse error"))?;
323 let avail = avail.ok_or_else(|| anyhow!("parse error"))?;
324 let total_mib = total.parse::<u64>()? as f64 / 1024.0;
325 let avail_mib = avail.parse::<u64>()? as f64 / 1024.0;
326
327 Ok(MemInfo {
328 total_mib,
329 avail_mib,
330 })
331}
332
333pub async fn get_disk_info() -> Result<DiskInfo> {
337 let mut cmd = Command::new("df");
338 let output = cmd.output().await?;
339 ensure!(output.status.success(), "df command failed");
340
341 let stdout = String::from_utf8_lossy(&output.stdout);
352 let mut total = None;
353 let mut avail = None;
354 for line in stdout.lines().skip(1) {
355 let mut total_tmp = None;
356 let mut avail_tmp = None;
357 let mut mp_tmp = None;
358 for (col_no, elem) in line.split_ascii_whitespace().enumerate() {
359 match col_no {
360 1 => total_tmp = Some(elem),
361 3 => avail_tmp = Some(elem),
362 5 => mp_tmp = Some(elem),
363 _ => (),
364 }
365 }
366 if let Some(mp) = mp_tmp
367 && mp == "/"
368 {
369 total = total_tmp;
370 avail = avail_tmp;
371 }
372 }
373 let total = total.ok_or_else(|| anyhow!("parse error"))?;
374 let avail = avail.ok_or_else(|| anyhow!("parse error"))?;
375 let total_gib = total.parse::<u64>()? as f64 / 1024.0 / 1024.0;
376 let avail_gib = avail.parse::<u64>()? as f64 / 1024.0 / 1024.0;
377
378 Ok(DiskInfo {
379 total_gib,
380 avail_gib,
381 })
382}
383
384pub async fn get_cpu_temp() -> Result<Option<f64>> {
392 let result = tokio::fs::read("/sys/class/thermal/thermal_zone0/temp").await;
393 match result {
394 Ok(buf) => {
395 let text = String::from_utf8_lossy(&buf);
396 let temp = text.trim().parse::<f64>()? / 1000.0;
398
399 Ok(Some(temp))
400 }
401 Err(e) => {
402 if e.kind() == std::io::ErrorKind::NotFound {
403 Ok(None)
405 } else {
406 Err(anyhow::Error::from(e))
408 }
409 }
410 }
411}
412
413pub async fn get_cpu_cores() -> Result<u32> {
417 let output = Command::new("nproc").output().await?;
418 ensure!(output.status.success(), "nproc command failed");
419
420 let stdout = String::from_utf8_lossy(&output.stdout);
421
422 Ok(stdout.trim().parse()?)
423}
424
425pub async fn get_current_freq() -> Result<Option<u64>> {
430 let result = Command::new("vcgencmd")
431 .arg("measure_clock ")
432 .arg("arm")
433 .output()
434 .await;
435 let output = match result {
436 Ok(output) => output,
437 Err(e) => {
438 if e.kind() == std::io::ErrorKind::NotFound {
439 return Ok(None);
441 } else {
442 return Err(anyhow::Error::from(e));
444 }
445 }
446 };
447 ensure!(output.status.success(), "vcgencmd measure_clock failed");
448
449 let stdout = String::from_utf8_lossy(&output.stdout);
450 let actual = if let Some((_le, ri)) = stdout.trim().split_once('=') {
451 ri.parse::<u64>()?
452 } else {
453 return Err(anyhow!("Parse error"));
454 };
455
456 Ok(Some(actual))
457}
458
459pub async fn get_freq_conf() -> Result<Option<u64>> {
465 let result = Command::new("vcgencmd")
466 .arg("get_config")
467 .arg("arm_freq")
468 .output()
469 .await;
470 let output = match result {
471 Ok(output) => output,
472 Err(e) => {
473 if e.kind() == std::io::ErrorKind::NotFound {
474 return Ok(None);
476 } else {
477 return Err(anyhow::Error::from(e));
479 }
480 }
481 };
482 ensure!(output.status.success(), "vcgencmd get_config failed");
483
484 let stdout = String::from_utf8_lossy(&output.stdout);
485 let conf = if let Some((_le, ri)) = stdout.trim().split_once('=') {
486 ri.parse::<u64>()? * 1000 * 1000
488 } else {
489 return Err(anyhow!("Parse error"));
490 };
491
492 Ok(Some(conf))
493}
494
495bitflags! {
496 #[derive(Default)]
498 pub struct ThrottleFlags: u32 {
499 const UNDER_VOLTAGE = 0x1;
501 const ARM_FREQ_CAPPED = 0x2;
503 const THROTTLED = 0x4;
505 const SOFT_TEMP_LIMIT = 0x8;
507 const PAST_UNDER_VOLTAGE = 0x10000;
509 const PAST_ARM_FREQ_CAPPED = 0x20000;
511 const PAST_THROTTLED = 0x40000;
513 const PAST_SOFT_TEMP_LIMIT = 0x80000;
515 }
516}
517
518pub async fn get_throttle_status() -> Result<Option<ThrottleFlags>> {
523 let result = Command::new("vcgencmd").arg("get_throttled").output().await;
524 let output = match result {
525 Ok(output) => output,
526 Err(e) => {
527 if e.kind() == std::io::ErrorKind::NotFound {
528 return Ok(None);
530 } else {
531 return Err(anyhow::Error::from(e));
533 }
534 }
535 };
536 ensure!(output.status.success(), "vcgencmd get_throttled failed");
537
538 let stdout = String::from_utf8_lossy(&output.stdout);
539 let bits = if let Some((_le, ri)) = stdout.trim().split_once("=0x") {
540 u32::from_str_radix(ri, 16)?
541 } else {
542 return Err(anyhow!("Parse error"));
543 };
544 let status = ThrottleFlags::from_bits(bits).ok_or_else(|| anyhow!("Invalid bitflags"))?;
545
546 Ok(Some(status))
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[tokio::test]
554 async fn cpu_info() {
555 let info = get_cpu_info().await.unwrap();
556
557 assert!((0.0..=100.0).contains(&info.cpu_percent_total));
558
559 let temp = info.temp;
560 if cfg!(any(target_arch = "arm", target_arch = "aarch64")) {
561 let temp = temp.unwrap();
562 assert!(
563 (30.0..=100.0).contains(&temp),
564 "strange temperature: {temp}"
565 );
566 } else {
567 assert!(temp.is_none());
568 }
569 }
570
571 #[tokio::test]
572 async fn mem_info() {
573 let info = get_mem_info().await.unwrap();
574
575 assert!(info.avail_mib <= info.total_mib);
576 }
577
578 #[tokio::test]
579 async fn disk_info() {
580 let info = get_disk_info().await.unwrap();
581
582 assert!(info.avail_gib <= info.total_gib);
583 }
584
585 #[tokio::test]
586 async fn cpu_cores() {
587 let cores = get_cpu_cores().await.unwrap();
588 assert!((1..=256).contains(&cores), "CPU cores: {cores}");
589 }
590
591 #[tokio::test]
592 async fn cpu_freq() {
593 let cur = get_current_freq().await.unwrap();
594 let conf = get_freq_conf().await.unwrap();
595 if cfg!(any(target_arch = "arm", target_arch = "aarch64")) {
596 let cur = cur.unwrap();
597 let conf = conf.unwrap();
598 assert!(
600 (100_000_000..10_000_000_000).contains(&cur),
601 "CPU frequency: {cur} Hz"
602 );
603 assert!(
604 (100_000_000..10_000_000_000).contains(&conf),
605 "CPU frequency: {conf} Hz"
606 );
607 } else {
608 assert!(cur.is_none());
609 assert!(conf.is_none());
610 }
611 }
612
613 #[tokio::test]
614 async fn throttle_status() {
615 let flags = get_throttle_status().await.unwrap();
616 if cfg!(any(target_arch = "arm", target_arch = "aarch64")) {
617 assert!(flags.is_some());
619 } else {
620 assert!(flags.is_none());
621 }
622 }
623}