Add battery level to web UI

This commit is contained in:
Simon Fondrie-Teitler
2025-08-25 17:19:51 -04:00
committed by Cooper Quintin
parent f49d11f034
commit 663d0abb57
9 changed files with 273 additions and 2 deletions
+63
View File
@@ -0,0 +1,63 @@
use std::path::Path;
use rayhunter::Device;
use serde::Serialize;
use crate::error::RayhunterError;
pub mod orbic;
pub mod tmobile;
pub mod wingtech;
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
pub enum BatteryLevel {
VeryLow,
Low,
Medium,
High,
Full,
}
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
pub struct BatteryState {
level: BatteryLevel,
is_plugged_in: bool,
}
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
match tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('0') => Ok(false),
Some('1') => Ok(true),
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
}
}
async fn get_level_from_percentage_file(path: &Path) -> Result<BatteryLevel, RayhunterError> {
match tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.trim_end()
.parse()
{
Ok(0..=10) => Ok(BatteryLevel::VeryLow),
Ok(11..=25) => Ok(BatteryLevel::Low),
Ok(26..=50) => Ok(BatteryLevel::Medium),
Ok(51..=75) => Ok(BatteryLevel::High),
Ok(76..=100) => Ok(BatteryLevel::Full),
_ => Err(RayhunterError::BatteryLevelParseError),
}
}
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
Ok(match device {
Device::Orbic => orbic::get_battery_state().await?,
Device::Wingtech => wingtech::get_battery_state().await?,
Device::Tmobile => tmobile::get_battery_state().await?,
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
})
}
+28
View File
@@ -0,0 +1,28 @@
use std::path::Path;
use crate::{
battery::{BatteryLevel, BatteryState, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('1') => Ok(BatteryLevel::VeryLow),
Some('2') => Ok(BatteryLevel::Low),
Some('3') => Ok(BatteryLevel::Medium),
Some('4') => Ok(BatteryLevel::High),
Some('5') => Ok(BatteryLevel::Full),
_ => Err(RayhunterError::BatteryLevelParseError),
}?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}
+16
View File
@@ -0,0 +1,16 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}
+17
View File
@@ -0,0 +1,17 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str =
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}
+6
View File
@@ -15,4 +15,10 @@ pub enum RayhunterError {
QmdlStoreError(#[from] RecordingStoreError),
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
NoStoreDebugMode(String),
#[error("Error parsing file to determine battery level")]
BatteryLevelParseError,
#[error("Error parsing file to determine whether device is plugged in")]
BatteryPluggedInStatusParseError,
#[error("The requested functionality is not supported for this device")]
FunctionNotSupportedForDeviceError,
}
+1
View File
@@ -1,4 +1,5 @@
mod analysis;
mod battery;
mod config;
mod diag;
mod display;
+10 -1
View File
@@ -1,7 +1,9 @@
use std::sync::Arc;
use crate::qmdl_store::ManifestEntry;
use crate::battery::get_battery_status;
use crate::error::RayhunterError;
use crate::server::ServerState;
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
use axum::Json;
use axum::extract::State;
@@ -16,6 +18,8 @@ pub struct SystemStats {
pub disk_stats: DiskStats,
pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_status: Option<BatteryState>,
}
impl SystemStats {
@@ -24,6 +28,11 @@ impl SystemStats {
disk_stats: DiskStats::new(qmdl_path, device).await?,
memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await {
Ok(status) => Some(status),
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
Err(err) => return Err(err.to_string()),
},
})
}
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { type SystemStats } from '$lib/systemStats';
import { BatteryLevel, type SystemStats } from '$lib/systemStats';
let {
stats,
}: {
@@ -7,6 +7,65 @@
} = $props();
const table_cell_classes = 'border p-1 lg:p-2';
let battery_level = $derived.by(() => {
if (stats.battery_status === undefined) {
return 0;
}
switch (stats.battery_status.level) {
case BatteryLevel.Full:
return 100;
case BatteryLevel.High:
return 75;
case BatteryLevel.Medium:
return 50;
case BatteryLevel.Low:
return 25;
default:
return 10;
}
});
let bar_color = $derived.by(() => {
if (stats.battery_status === undefined) {
return '';
}
switch (stats.battery_status.level) {
case BatteryLevel.Low:
return 'fill-yellow-300';
case BatteryLevel.VeryLow:
return 'fill-red-500';
default:
return 'fill-green-500';
}
});
let title_text = $derived.by(() => {
if (stats.battery_status === undefined) {
return 'Rayhunter does not yet support displaying the battery level for this device.';
}
let text = '';
switch (stats.battery_status.level) {
case BatteryLevel.Full:
text = 'Full';
break;
case BatteryLevel.High:
text = 'High';
break;
case BatteryLevel.Medium:
text = 'Medium';
break;
case BatteryLevel.Low:
text = 'Low';
break;
case BatteryLevel.VeryLow:
text = 'Very low';
break;
}
if (stats.battery_status.is_plugged_in) {
text += ', plugged in';
}
return text;
});
</script>
<div
@@ -32,6 +91,64 @@
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
<tr class="border-b">
<th class={table_cell_classes}> Battery </th>
<td class={table_cell_classes}>
<svg
width="80"
height="30"
viewBox="0 0 80 30"
role="img"
xmlns="http://www.w3.org/2000/svg"
class="battery-icon"
>
<title>{title_text}</title>
<!-- Battery body -->
<rect
class="fill-none stroke-neutral-800 stroke-2"
width="70"
height="30"
rx="3"
ry="3"
/>
<!-- Battery terminal -->
<rect
class="fill-neutral-800"
x="70"
y="7"
width="8"
height="16"
rx="2"
ry="2"
/>
<!-- Battery charge bar -->
<rect
class={bar_color}
x="2"
y="2"
height="26"
rx="2"
ry="2"
style="width: {battery_level * 0.66}px;"
/>
{#if stats.battery_status && stats.battery_status.is_plugged_in}
<!-- Lightning bolt icon -->
<path
class="fill-yellow-300 stroke-neutral-800 stroke-1"
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
/>
{/if}
{#if !stats.battery_status}
<!-- Question mark icon -->
<text
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
x="35"
y="15">?</text
>
{/if}
</svg>
</td>
</tr>
</tbody>
</table>
</div>
+14
View File
@@ -2,6 +2,7 @@ export interface SystemStats {
disk_stats: DiskStats;
memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata;
battery_status?: BatteryStatus;
}
export interface RuntimeMetadata {
@@ -24,3 +25,16 @@ export interface MemoryStats {
used: string;
free: string;
}
export interface BatteryStatus {
level: BatteryLevel;
is_plugged_in: boolean;
}
export enum BatteryLevel {
VeryLow = 'VeryLow',
Low = 'Low',
Medium = 'Medium',
High = 'High',
Full = 'Full',
}