「バッテリー駆動中は同期を止める」「AC アダプタが抜かれた瞬間に省電力モードへ切り替える」といった制御には、残量だけでなく充電/放電の状態、残り時間、消費電力、電池の健康度が必要です。Tauri 本体にこの API はないため、Rust の starship-battery クレートをコマンドで包み、バックグラウンドスレッドで定期的に読み取って変化をイベント通知します。残量を 1 回読むだけの短い書き方は 2 章の最後に載せています。
battery クレート (0.7.x) は 2020 年以降更新が止まっているので、同じ API を引き継いで保守されているフォーク starship-battery(モジュール名 starship_battery)を使います。
前提条件
cd src-tauri
cargo add starship-battery
cargo add serde --features derive
イベント送受信は core:event:default で既定許可されているため、capabilities の追加は不要です。
1. フロントエンドから実装する (TypeScript)
初回は invoke で即時取得し、以降は power-status イベントで更新します。AC の抜き差しは別イベント power-source-changed で受けると、通知やモード切替のトリガーにしやすくなります。
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
interface BatteryInfo {
state: 'Charging' | 'Discharging' | 'Full' | 'Empty' | 'Unknown';
percent: number; // 0〜100
healthPercent: number; // 設計容量に対する現在の満充電容量
energyRateWatt: number; // 充放電の電力 (W)
timeToFullSec: number | null;
timeToEmptySec: number | null;
cycleCount: number | null;
}
interface PowerStatus {
hasBattery: boolean;
onAc: boolean;
batteries: BatteryInfo[];
}
const fmt = (sec: number | null) =>
sec === null ? '--' : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
function render(s: PowerStatus) {
if (!s.hasBattery) { console.log('バッテリー非搭載 (AC 電源のみ)'); return; }
const b = s.batteries[0];
console.log(`${b.state} ${b.percent.toFixed(0)}% 健康度 ${b.healthPercent.toFixed(0)}% ` +
`${b.energyRateWatt.toFixed(1)}W 満充電まで ${fmt(b.timeToFullSec)} 残り ${fmt(b.timeToEmptySec)}`);
}
render(await invoke<PowerStatus>('get_power_status'));
await listen<PowerStatus>('power-status', (e) => render(e.payload));
await listen<{ onAc: boolean }>('power-source-changed', (e) => {
console.log(e.payload.onAc ? 'AC 電源に接続されました' : 'バッテリー駆動に切り替わりました');
});
2. バックエンドから実装する (Rust)
Manager::batteries() がバッテリーを列挙します。値は uom の単位付き型なので get::<percent>() などで数値化します。AC 判定は「Discharging のバッテリーが 1 つもなければ AC あり」とするのが実機で最も安定します(Unknown を返す機種があるため Charging | Full の肯定判定は誤検知しやすい)。バッテリーが無いデスクトップ PC はこの式で自然に AC 扱いになります。
// src-tauri/src/lib.rs
use starship_battery::units::{power::watt, ratio::percent, time::second};
use starship_battery::{Manager, State};
use std::time::Duration;
use tauri::Emitter;
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct BatteryInfo {
state: String,
percent: f32,
health_percent: f32,
energy_rate_watt: f32,
time_to_full_sec: Option<u64>,
time_to_empty_sec: Option<u64>,
cycle_count: Option<u32>,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PowerStatus {
has_battery: bool,
on_ac: bool,
batteries: Vec<BatteryInfo>,
}
fn read_power_status() -> Result<PowerStatus, String> {
let manager = Manager::new().map_err(|e| e.to_string())?;
let mut batteries = Vec::new();
let mut discharging = false;
for item in manager.batteries().map_err(|e| e.to_string())? {
let b = item.map_err(|e| e.to_string())?;
discharging |= b.state() == State::Discharging;
batteries.push(BatteryInfo {
state: format!("{:?}", b.state()),
percent: b.state_of_charge().get::<percent>(),
health_percent: b.state_of_health().get::<percent>(),
energy_rate_watt: b.energy_rate().get::<watt>(),
time_to_full_sec: b.time_to_full().map(|t| t.get::<second>() as u64),
time_to_empty_sec: b.time_to_empty().map(|t| t.get::<second>() as u64),
cycle_count: b.cycle_count(),
});
}
Ok(PowerStatus { has_battery: !batteries.is_empty(), on_ac: !discharging, batteries })
}
#[tauri::command]
fn get_power_status() -> Result<PowerStatus, String> {
read_power_status()
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SourceChanged { on_ac: bool }
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let handle = app.handle().clone();
std::thread::spawn(move || {
let mut last_on_ac: Option<bool> = None;
loop {
if let Ok(status) = read_power_status() {
if last_on_ac.is_some() && last_on_ac != Some(status.on_ac) {
let _ = handle.emit("power-source-changed", SourceChanged { on_ac: status.on_ac });
}
last_on_ac = Some(status.on_ac);
let _ = handle.emit("power-status", status);
}
std::thread::sleep(Duration::from_secs(5));
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![get_power_status])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
間隔は 5 秒にしています。残量表示だけなら 30〜60 秒で十分ですが、AC の抜き差しをすぐ反映したいなら 2〜5 秒が現実的な下限です。読み取りは sysfs や電源 API の参照だけなので負荷は問題になりません。
残量だけを 1 回取得する
設定画面に残量を出すだけなら、監視スレッドは要りません。呼ばれるたびに読み取るコマンドで足ります。気を付けたいのは、バッテリーを 2 つ積んだノート PC です。batteries() はバッテリーごとに値を返すので、1 章の render() のように先頭だけを見ると片方の残量になり、2 つの % を単純に平均すると容量の違いが無視されます。全体の残量は、残っているエネルギーの合計を満充電時の合計で割って求めます。1 つだけなら、ドライバーが報告する値を使う state_of_charge() の方が自前の割り算より正確です。
// 上の lib.rs に追記する(Manager と percent は上の use をそのまま使う)
use starship_battery::units::energy::watt_hour;
/// 残量 (%) を 1 回だけ返す。バッテリーが無ければ None、複数なら容量で重み付けする
#[tauri::command]
fn get_battery_percent() -> Result<Option<f32>, String> {
let manager = Manager::new().map_err(|e| e.to_string())?;
let mut list = Vec::new();
for item in manager.batteries().map_err(|e| e.to_string())? {
list.push(item.map_err(|e| e.to_string())?);
}
Ok(match list.as_slice() {
[] => None,
[one] => Some(one.state_of_charge().get::<percent>()),
many => {
let now: f32 = many.iter().map(|b| b.energy().get::<watt_hour>()).sum();
let full: f32 = many.iter().map(|b| b.energy_full().get::<watt_hour>()).sum();
(full > 0.0).then(|| now / full * 100.0)
}
})
}
登録は generate_handler![get_power_status, get_battery_percent] のように並べます。None は JS では null になるので、バッテリーの無い PC は「表示しない」に分岐できます。
import { invoke } from '@tauri-apps/api/core';
const percent = await invoke<number | null>('get_battery_percent');
console.log(percent === null ? 'バッテリーなし' : `残量 ${Math.round(percent)}%`);
動作確認
npm run tauri dev を起動し、ノート PC で AC アダプタを抜き差しすると 5 秒以内にログが流れます。
Discharging 83% 健康度 91% 12.4W 満充電まで -- 残り 3h 40m
バッテリー駆動に切り替わりました
Charging 83% 健康度 91% 31.0W 満充電まで 0h 35m 残り --
AC 電源に接続されました
デスクトップ PC では hasBattery: false, onAc: true, batteries: [] が返り、power-source-changed は発火しません。
よくあるエラーと対処法
Manager::new()やbatteries()が Err: Linux で/sys/class/power_supplyが読めない(コンテナや一部 VM)場合です。フロントに投げずhas_battery: falseに倒す分岐を入れると UI が壊れません。timeToFullSec/timeToEmptySecが常にnull: 充電中はtime_to_emptyが、放電中はtime_to_fullがNoneになるのは仕様です。両方nullなのは接続変化直後で OS がまだ推定していない状態で、次のポーリングで埋まります。stateがUnknownのまま: 充電しきい値を設定した ThinkPad など満充電付近で充電を止める機種で起きます。AC 判定をDischargingの否定にしていればこのケースも「AC あり」になります。- 旧
batteryクレートでビルドエラー: 依存のuom/core-foundationが古く、新しいツールチェーンや macOS SDK で通らないことがあります。use battery::をuse starship_battery::に変えるだけで移行できます。
OS ごとの違いと注意点
- Windows:
cycle_countやvendor()が取れない機種が多くnullが普通です。healthPercentはpowercfg /batteryreportの値とほぼ一致します。 - macOS:
cycle_countとtemperature()が安定して取れます。Apple Silicon ではアイドル時のenergy_rateが 0 近くまで下がり、放電中でも値が揺れます。 - Linux:
/sys/class/power_supply/BAT0などを読みます。UPS を接続したデスクトップでは UPS がバッテリーとして列挙されることがあります。 - 監視スレッドはアプリ終了時にプロセスごと終了します。明示的に止めたい場合は
AtomicBoolを State に持たせてループ条件にしてください。
