diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx
index 9a0d12c..eb97f3f 100644
--- a/frontend/src/components/StatusBar.tsx
+++ b/frontend/src/components/StatusBar.tsx
@@ -1,10 +1,24 @@
-import { useEffect, useState } from 'react';
-import { Menu, Moon, Sun } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+import {
+ BatteryFull,
+ BatteryLow,
+ BatteryMedium,
+ BatteryWarning,
+ Menu,
+ Moon,
+ Sun,
+} from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
+import {
+ BATTERY_DISPLAY_CHANGE_EVENT,
+ getShowBatteryPercent,
+ getShowBatteryVoltage,
+ mvToPercent,
+} from '../utils/batteryDisplay';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -22,6 +36,35 @@ export function StatusBar({
onSettingsClick,
onMenuClick,
}: StatusBarProps) {
+ const [showBatteryPercent, setShowBatteryPercent] = useState(getShowBatteryPercent);
+ const [showBatteryVoltage, setShowBatteryVoltage] = useState(getShowBatteryVoltage);
+
+ useEffect(() => {
+ const handler = () => {
+ setShowBatteryPercent(getShowBatteryPercent());
+ setShowBatteryVoltage(getShowBatteryVoltage());
+ };
+ window.addEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
+ return () => window.removeEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
+ }, []);
+
+ const batteryMv = health?.radio_stats?.battery_mv;
+ const batteryInfo = useMemo(() => {
+ if ((!showBatteryPercent && !showBatteryVoltage) || !batteryMv || batteryMv <= 0) return null;
+ const pct = mvToPercent(batteryMv);
+ const Icon =
+ pct >= 80 ? BatteryFull : pct >= 40 ? BatteryMedium : pct >= 15 ? BatteryLow : BatteryWarning;
+ const color =
+ pct >= 40 ? 'text-status-connected' : pct >= 15 ? 'text-warning' : 'text-destructive';
+ const label =
+ showBatteryPercent && showBatteryVoltage
+ ? `${pct}% (${batteryMv}mV)`
+ : showBatteryPercent
+ ? `${pct}%`
+ : `${batteryMv}mV`;
+ return { pct, Icon, color, label, mv: batteryMv };
+ }, [batteryMv, showBatteryPercent, showBatteryVoltage]);
+
const radioState =
health?.radio_state ??
(health?.radio_initializing
@@ -119,6 +162,18 @@ export function StatusBar({
{statusLabel}
+ {connected && batteryInfo && (
+
+
+ {batteryInfo.label}
+
+ )}
+
{config && (
{config.name || 'Unnamed'}
diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx
index 29aab85..b706dea 100644
--- a/frontend/src/components/settings/SettingsLocalSection.tsx
+++ b/frontend/src/components/settings/SettingsLocalSection.tsx
@@ -28,6 +28,13 @@ import {
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
+import {
+ BATTERY_DISPLAY_CHANGE_EVENT,
+ getShowBatteryPercent,
+ setShowBatteryPercent as saveBatteryPercent,
+ getShowBatteryVoltage,
+ setShowBatteryVoltage as saveBatteryVoltage,
+} from '../../utils/batteryDisplay';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -50,6 +57,8 @@ export function SettingsLocalSection({
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
+ const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
+ const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -201,6 +210,43 @@ export function SettingsLocalSection({
Auto-focus input on conversation load (desktop only)
+
+
+
+
+ {(batteryPercent || batteryVoltage) && (
+
+ Battery data updates every 60 seconds and may take up to a minute to appear after
+ connecting.
+
+ )}
+
diff --git a/frontend/src/test/batteryDisplay.test.ts b/frontend/src/test/batteryDisplay.test.ts
new file mode 100644
index 0000000..c79eca6
--- /dev/null
+++ b/frontend/src/test/batteryDisplay.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { mvToPercent, formatBatteryLabel } from '../utils/batteryDisplay';
+
+describe('mvToPercent', () => {
+ it('clamps to 100 above table ceiling', () => {
+ expect(mvToPercent(4500)).toBe(100);
+ expect(mvToPercent(4190)).toBe(100);
+ });
+
+ it('clamps to 0 below table floor', () => {
+ expect(mvToPercent(3100)).toBe(0);
+ expect(mvToPercent(2800)).toBe(0);
+ });
+
+ it('returns exact table values at boundaries', () => {
+ expect(mvToPercent(4050)).toBe(90);
+ expect(mvToPercent(3630)).toBe(40);
+ });
+
+ it('interpolates between table entries', () => {
+ // Midpoint between 3630 (40%) and 3720 (50%) = 3675 → ~45%
+ const mid = mvToPercent(3675);
+ expect(mid).toBeGreaterThan(40);
+ expect(mid).toBeLessThan(50);
+ });
+});
+
+describe('formatBatteryLabel', () => {
+ it('returns null when both toggles are off', () => {
+ expect(formatBatteryLabel(4050, false, false)).toBeNull();
+ });
+
+ it('returns percentage only', () => {
+ expect(formatBatteryLabel(4050, true, false)).toBe('90%');
+ });
+
+ it('returns voltage only', () => {
+ expect(formatBatteryLabel(4050, false, true)).toBe('4050mV');
+ });
+
+ it('returns combined when both enabled', () => {
+ expect(formatBatteryLabel(4050, true, true)).toBe('90% (4050mV)');
+ });
+});
diff --git a/frontend/src/utils/batteryDisplay.ts b/frontend/src/utils/batteryDisplay.ts
new file mode 100644
index 0000000..a5c12ec
--- /dev/null
+++ b/frontend/src/utils/batteryDisplay.ts
@@ -0,0 +1,83 @@
+export const BATTERY_DISPLAY_CHANGE_EVENT = 'remoteterm-battery-display-change';
+
+// Meshtastic default OCV table (meshtastic/firmware src/power.h)
+const OCV_TABLE: [number, number][] = [
+ [4190, 100],
+ [4050, 90],
+ [3990, 80],
+ [3890, 70],
+ [3800, 60],
+ [3720, 50],
+ [3630, 40],
+ [3530, 30],
+ [3420, 20],
+ [3300, 10],
+ [3100, 0],
+];
+
+export function mvToPercent(mv: number): number {
+ if (mv >= OCV_TABLE[0][0]) return 100;
+ if (mv <= OCV_TABLE[OCV_TABLE.length - 1][0]) return 0;
+ for (let i = 0; i < OCV_TABLE.length - 1; i++) {
+ const [highMv, highPct] = OCV_TABLE[i];
+ const [lowMv, lowPct] = OCV_TABLE[i + 1];
+ if (mv >= lowMv)
+ return Math.round(lowPct + ((mv - lowMv) / (highMv - lowMv)) * (highPct - lowPct));
+ }
+ return 0;
+}
+
+export function formatBatteryLabel(
+ mv: number,
+ showPercent: boolean,
+ showVoltage: boolean
+): string | null {
+ if (!showPercent && !showVoltage) return null;
+ const pct = mvToPercent(mv);
+ if (showPercent && showVoltage) return `${pct}% (${mv}mV)`;
+ if (showPercent) return `${pct}%`;
+ return `${mv}mV`;
+}
+
+const PERCENT_KEY = 'remoteterm-show-battery-percent';
+const VOLTAGE_KEY = 'remoteterm-show-battery-voltage';
+
+export function getShowBatteryPercent(): boolean {
+ try {
+ return localStorage.getItem(PERCENT_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+export function setShowBatteryPercent(enabled: boolean): void {
+ try {
+ if (enabled) {
+ localStorage.setItem(PERCENT_KEY, 'true');
+ } else {
+ localStorage.removeItem(PERCENT_KEY);
+ }
+ } catch {
+ // localStorage may be unavailable
+ }
+}
+
+export function getShowBatteryVoltage(): boolean {
+ try {
+ return localStorage.getItem(VOLTAGE_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+export function setShowBatteryVoltage(enabled: boolean): void {
+ try {
+ if (enabled) {
+ localStorage.setItem(VOLTAGE_KEY, 'true');
+ } else {
+ localStorage.removeItem(VOLTAGE_KEY);
+ }
+ } catch {
+ // localStorage may be unavailable
+ }
+}