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 && ( +
+
+ )} + {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 + } +}