バッテリー状態と電源接続を取得・監視する

starship-battery クレートで残量・充電状態・残り時間・健康度・消費電力を取得し、Rust 側の定期ポーリングで AC 電源の抜き差しをイベント通知する。複数バッテリーや非搭載 PC の扱いも示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約9分 hw-016
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 残量だけを 1 回取得する
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

「バッテリー駆動中は同期を止める」「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 に持たせてループ条件にしてください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する